abelien-harness 0.5.0 → 0.6.1

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 (95) hide show
  1. package/.env.example +1 -0
  2. package/README.md +11 -18
  3. package/package.json +2 -3
  4. package/skills/abelien-appearance/SKILL.md +1 -0
  5. package/skills/abelien-clarify/SKILL.md +12 -2
  6. package/skills/abelien-clarify/references/examples.md +22 -0
  7. package/skills/abelien-clarify/scripts/check_result.py +38 -3
  8. package/skills/abelien-clarify/scripts/test_check_result.py +32 -1
  9. package/skills/abelien-guide/SKILL.md +1 -1
  10. package/skills/abelien-hardware/SKILL.md +4 -4
  11. package/skills/abelien-hardware/domain.json +1 -1
  12. package/skills/abelien-hardware/references/search-index-format.md +4 -2
  13. package/skills/abelien-hardware/references/web-evidence-contract.md +3 -3
  14. package/skills/abelien-hardware/scripts/dimensions.mjs +23 -8
  15. package/skills/abelien-hardware/scripts/electrical.mjs +62 -19
  16. package/skills/abelien-hardware/scripts/finalize-selection.mjs +3 -5
  17. package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +17 -0
  18. package/skills/abelien-hardware/scripts/public-page.mjs +11 -121
  19. package/skills/abelien-hardware/scripts/response-redaction.mjs +1 -130
  20. package/skills/abelien-hardware/scripts/selection-request.mjs +6 -5
  21. package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +9 -6
  22. package/skills/abelien-hardware/scripts/volc-web-search.mjs +10 -32
  23. package/skills/abelien-hardware/scripts/web-common.mjs +4 -10
  24. package/skills/abelien-hardware/scripts/web-integrity.mjs +1 -1
  25. package/skills/abelien-hardware/scripts/web-provider-chain.mjs +2 -1
  26. package/skills/abelien-hardware/scripts/web-receipt.mjs +4 -2
  27. package/skills/abelien-hardware/scripts/web-search-plan.mjs +5 -1
  28. package/skills/abelien-hardware/scripts/web-search.mjs +4 -3
  29. package/skills/abelien-hardware-selection/domain.json +2 -2
  30. package/skills/abelien-hardware-selection/references/selection-service-contract.md +18 -10
  31. package/skills/abelien-hardware-selection/scripts/select.mjs +13 -5
  32. package/skills/abelien-harness/scripts/ab.mjs +24 -2
  33. package/skills/abelien-harness/scripts/web/common.mjs +20 -0
  34. package/skills/abelien-harness/scripts/web/public-read.mjs +168 -0
  35. package/skills/abelien-harness/scripts/web/redact.mjs +136 -0
  36. package/skills/abelien-harness/scripts/web/volc.mjs +49 -0
  37. package/skills/abelien-reference/SKILL.md +59 -0
  38. package/skills/abelien-reference/domain.json +92 -0
  39. package/skills/abelien-reference/fixtures/create.final.json +104 -0
  40. package/skills/abelien-reference/fixtures/restore.collected.json +191 -0
  41. package/skills/abelien-reference/fixtures/restore.final.json +269 -0
  42. package/skills/abelien-reference/references/research-contract.md +78 -0
  43. package/skills/abelien-reference/schemas/reference-evidence.v1.json +153 -0
  44. package/skills/abelien-reference/scripts/evidence.mjs +68 -0
  45. package/skills/abelien-reference/scripts/finalize.mjs +116 -0
  46. package/skills/abelien-reference/scripts/measure.mjs +62 -0
  47. package/skills/abelien-reference/scripts/png.mjs +86 -0
  48. package/skills/abelien-reference/scripts/research.mjs +191 -0
  49. package/skills/abelien-structure/SKILL.md +2 -2
  50. package/skills/abelien-structure/domain.json +64 -0
  51. package/skills/abelien-structure/references/build123d-execution.md +1 -1
  52. package/skills/abelien-structure/references/handoff.md +1 -1
  53. package/skills/abelien-structure/worker/abelien_cad.py +585 -0
  54. package/skills/abelien-structure/worker/assembly-validation.mjs +106 -0
  55. package/skills/abelien-structure/worker/cad.py +1623 -0
  56. package/skills/abelien-structure/worker/cli-adapter.mjs +207 -0
  57. package/skills/abelien-structure/worker/component-fit-glb.mjs +173 -0
  58. package/skills/abelien-structure/worker/component-fit.d.mts +82 -0
  59. package/skills/abelien-structure/worker/component-fit.mjs +167 -0
  60. package/skills/abelien-structure/worker/component-target-image.d.mts +23 -0
  61. package/skills/abelien-structure/worker/component-target-image.mjs +107 -0
  62. package/skills/abelien-structure/worker/component-target-render.py +58 -0
  63. package/skills/abelien-structure/worker/config.mjs +6 -0
  64. package/skills/abelien-structure/worker/engineering-fit.d.mts +48 -0
  65. package/skills/abelien-structure/worker/engineering-fit.mjs +118 -0
  66. package/skills/abelien-structure/worker/engineering-policy.json +16 -0
  67. package/skills/abelien-structure/worker/engineering-repair.py +147 -0
  68. package/skills/abelien-structure/worker/engineering.py +324 -0
  69. package/skills/abelien-structure/worker/identity.mjs +98 -0
  70. package/skills/abelien-structure/worker/inspection-geometry.mjs +71 -0
  71. package/skills/abelien-structure/worker/jobs.mjs +451 -0
  72. package/skills/abelien-structure/worker/library-runtime.mjs +15 -0
  73. package/skills/abelien-structure/worker/main.mjs +102 -0
  74. package/skills/abelien-structure/worker/manifest-digest.mjs +11 -0
  75. package/skills/abelien-structure/worker/materials.mjs +52 -0
  76. package/skills/abelien-structure/worker/native-launcher.py +13 -0
  77. package/skills/abelien-structure/worker/native.mjs +157 -0
  78. package/skills/abelien-structure/worker/representation.mjs +47 -0
  79. package/skills/abelien-structure/worker/sandbox.mjs +41 -0
  80. package/skills/abelien-structure/worker/source-contract.mjs +143 -0
  81. package/skills/abelien-structure/worker/source_call_stats.py +59 -0
  82. package/skills/abelien-structure/worker/supervisor.mjs +38 -0
  83. package/skills/abelien-workspace/SKILL.md +11 -0
  84. package/skills/abelien-workspace/assets/journey-stage-plan.json +155 -0
  85. package/skills/abelien-workspace/domain.json +25 -2
  86. package/skills/abelien-workspace/references/hardware-journey-orchestration.md +1 -1
  87. package/skills/abelien-workspace/references/harness-binding.md +2 -2
  88. package/skills/abelien-workspace/references/user-journey.md +10 -0
  89. package/skills/abelien-workspace/scripts/journey-next.mjs +38 -0
  90. package/skills/abelien-workspace/workflows/definitions/399ef372790b7330d0ef88ce5096e7f69943d4793de97b2ed6c25165a332612f.json +230 -0
  91. package/skills/abelien-workspace/workflows/definitions/591a7f21e0b0ad586e340163f7533ac910356091ba8d2a26e250c0acbc80cd8b.json +176 -0
  92. package/skills/abelien-workspace/workflows/definitions/d9f8c6cdf0b2c41cdeb1c72ac5d1e37d95a7caac3272d434a3c7e8483fc565de.json +150 -0
  93. package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +3 -2
  94. package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +3 -2
  95. package/skills/abelien-workspace/workflows/user-journey-hardware.json +3 -2
@@ -1,9 +1,7 @@
1
+ // Generic helpers live in the kernel (P2-13.03); this module binds hardware's codes and keeps hardware-only checks.
1
2
  import { selectionRequestDigest, digest, validateSelectionSlots } from "./selection-request.mjs";
2
- export const record = value => value && typeof value === "object" && !Array.isArray(value);
3
- export const text = value => typeof value === "string" && value.trim().length > 0;
4
- export function failure(code, message, details) { return Object.assign(new Error(message), { code, ...(details ? { details } : {}) }); }
5
- export function invalid(message) { throw failure("VALIDATION", message); }
6
- export function onlyKeys(value, keys, label) { if (!record(value) || Object.keys(value).some(key => !keys.includes(key))) invalid(`${label} has unsupported fields`); }
3
+ import { record, text, failure, invalid, timed as kernelTimed } from "../../abelien-harness/scripts/web/common.mjs";
4
+ export { record, text, failure, invalid, onlyKeys } from "../../abelien-harness/scripts/web/common.mjs";
7
5
  export async function boundRecall(input, readResource) {
8
6
  if (typeof readResource !== "function" || !/^hardware-recall:[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(input?.recall_id ?? "")) invalid("persisted recall_id and trusted resource reader required");
9
7
  if (!record(input.requirements)) invalid("original requirements required");
@@ -29,8 +27,4 @@ export function webConfig(env = {}) {
29
27
  pageTimeoutMs: positiveEnv(env, "ABELIEN_HARDWARE_WEB_PAGE_TIMEOUT_MS", 30000),
30
28
  parseTimeoutMs: positiveEnv(env, "ABELIEN_HARDWARE_WEB_PARSE_TIMEOUT_MS", 120000) };
31
29
  }
32
- export async function timed(operation, timeoutMs, code = "HARDWARE_WEB_TIMEOUT") {
33
- const controller = new AbortController(); let timer;
34
- try { return await Promise.race([operation(controller.signal), new Promise((_, reject) => { timer = setTimeout(() => { reject(failure(code, "hardware network request timed out")); controller.abort(); }, timeoutMs); })]); }
35
- finally { clearTimeout(timer); }
36
- }
30
+ export const timed = (operation, timeoutMs, code = "HARDWARE_WEB_TIMEOUT") => kernelTimed(operation, timeoutMs, code, "hardware network request timed out");
@@ -115,7 +115,7 @@ export function verifyCheckpointExtension(checkpoint, latest, readBlob) {
115
115
  try {
116
116
  const before = checkpoint?.body, after = latest?.body;
117
117
  if (!before || !after || checkpoint.id !== latest.id || checkpoint.kind !== "hardware-web-evidence" || latest.kind !== checkpoint.kind
118
- || !fixed(before, after, ["id", "provider", "protocol", "schema_version", "provider_policy", "recall_id", "recall_digest", "request_digest", "requirements", "slots", "plan_digest", "unresolved_slots"])
118
+ || !fixed(before, after, ["id", "provider", "protocol", "schema_version", "provider_policy", "recall_id", "recall_digest", "request_digest", "requirements", "slots", "plan_digest", "unresolved_slots", "prismer_switch"])
119
119
  || !prefix(before.query_plans, after.query_plans) || !prefix(before.unknowns, after.unknowns)
120
120
  || !prefix(before.searches, after.searches, before.protocol === chainProtocol ? group : attempt) || !prefix(before.pages, after.pages, page)) reject();
121
121
  } catch { reject(); }
@@ -6,7 +6,7 @@ import { prismerRequest } from "./prismer.mjs";
6
6
  import { volcQueryRequest, validateVolcCapability, volcWebSearch } from "./volc-web-search.mjs";
7
7
  import { jsonReceipt } from "./web-receipt.mjs";
8
8
  import { redactHardwareResponse } from "./response-redaction.mjs";
9
- import { chainPolicy, chainProtocol, receiptFields, primaryQuery, sourceOperation, operationId, permitsFallback, expectedSelection, selectedReceipt, queryResults, projectQuery, sourceProjection, trustedResult, validateChainIntegrity } from "./provider-chain-contract.mjs";
9
+ import { chainPolicy, chainProtocol, pinPrismerSwitch, receiptFields, primaryQuery, sourceOperation, operationId, permitsFallback, expectedSelection, selectedReceipt, queryResults, projectQuery, sourceProjection, trustedResult, validateChainIntegrity } from "./provider-chain-contract.mjs";
10
10
 
11
11
  // An effect is dispatched only after its original attempt has been saved. Receipt
12
12
  // persistence is outside the request catch: a storage failure cannot authorize fallback.
@@ -29,6 +29,7 @@ export async function searchProviderChain({ input, source, existing, env, readRe
29
29
  // Parse observations update the authoritative array and projection together.
30
30
  for (const page of evidence.pages) if (page.selected_attempt_id) page.attempt = selectedReceipt(page);
31
31
  const config = input.unresolved_slots.length ? webConfig(env) : null;
32
+ if (config) config.prismer = pinPrismerSwitch(evidence, config.prismer);
32
33
  const request = (path, body, timeoutMs, method = "POST") => prismerRequest(path, { body, env, fetchImpl, timeoutMs, method });
33
34
  const redact = raw => redactHardwareResponse(raw, env);
34
35
  const checkUrl = url => {
@@ -1,8 +1,11 @@
1
1
  import { digest } from "./selection-request.mjs";
2
2
  import { text } from "./web-common.mjs";
3
3
  import { sha256 } from "./public-page.mjs";
4
+ import { validVolcResult, volcRequestId } from "../../abelien-harness/scripts/web/volc.mjs";
5
+ export { validVolcResult };
4
6
  export const receiptRequestId = attempt => {
5
- const id = attempt?.provider === "volcengine" ? attempt.response?.ResponseMetadata?.RequestId : attempt?.response?.requestId;
7
+ if (attempt?.provider === "volcengine") return volcRequestId(attempt.response);
8
+ const id = attempt?.response?.requestId;
6
9
  return text(id) ? id : null;
7
10
  };
8
11
  export function intactReceipt(attempt) {
@@ -14,7 +17,6 @@ export const successfulReceipt = attempt => intactReceipt(attempt) && attempt.ht
14
17
  && (attempt.provider === "volcengine" ? validVolcResult(attempt.response)
15
18
  : attempt.provider === "direct_https" ? ["read", "unavailable"].includes(attempt.response?.status)
16
19
  : attempt.response?.success === true && !attempt.response.error);
17
- export const validVolcResult = response => text(response?.ResponseMetadata?.RequestId) && !response.ResponseMetadata.Error && response.success !== false && Array.isArray(response.Result?.WebResults);
18
20
  export function jsonReceipt(response, httpStatus, provider, envRedact) {
19
21
  const raw = envRedact(JSON.stringify(response)), value = JSON.parse(raw);
20
22
  const saved = { raw, raw_sha256: sha256(raw), response: value, response_sha256: digest(value), received_at: new Date().toISOString(), http_status: httpStatus };
@@ -6,8 +6,12 @@ const cjk = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hang
6
6
  // Shared read-only plan validation. Do not normalize model intent or access
7
7
  // credentials/configuration/checkpoints before this original-source binding.
8
8
  // The shape check alone is used for a plan already frozen in saved evidence.
9
+ // plan_rules is an explicit opt-in written by the current workflow definitions; a run pinned
10
+ // to an older definition omits it and keeps the rules its definition was published with.
11
+ export const currentPlanRules = "volc-english-5";
9
12
  export async function validateFrozenHardwareWebPlan({ input, readResource }) {
10
- onlyKeys(input, ["requirements", "slots", "recall_id", "unresolved_slots"], "web search input");
13
+ onlyKeys(input, ["requirements", "slots", "recall_id", "unresolved_slots", "plan_rules"], "web search input");
14
+ if (Object.hasOwn(input, "plan_rules") && input.plan_rules !== currentPlanRules) invalid(`plan_rules must be ${currentPlanRules}`);
11
15
  const source = await boundRecall(input, readResource), recall = source.body;
12
16
  if (!Array.isArray(input.unresolved_slots)) invalid("unresolved_slots must be an array");
13
17
  const unresolved = new Set();
@@ -6,7 +6,7 @@ import { prismerRequest } from "./prismer.mjs";
6
6
  import { queryRequest, validateHardwareWebIntegrity, verifyCheckpointExtension, trustedParseResult } from "./web-integrity.mjs";
7
7
  import { chainProtocol } from "./provider-chain-contract.mjs";
8
8
  import { searchProviderChain } from "./web-provider-chain.mjs";
9
- import { validateFrozenHardwareWebPlan, validateNewHardwareWebPlan } from "./web-search-plan.mjs";
9
+ import { validateFrozenHardwareWebPlan, validateNewHardwareWebPlan, currentPlanRules } from "./web-search-plan.mjs";
10
10
  const provider = "prismer", protocol = "prismer-context-parse-v1";
11
11
  const unresolvedAttempt = attempt => ["in_progress", "unknown", "failed"].includes(attempt?.status);
12
12
  export async function verifyHardwareWebRecovery({ input, readResource, readBlob }) {
@@ -23,8 +23,9 @@ export async function searchHardwareWeb({ input, env = {}, readResource, fetchIm
23
23
  const existing = resolveWebEvidence(storedExisting, readBlob);
24
24
  const pack = body => packWebEvidence(body, writeBlob, !existing && env.AB_HARNESS_AUDIT_V1 === "true");
25
25
  if (existing && (existing.kind !== "hardware-web-evidence" || existing.body?.recall_id !== binding.recall_id || existing.body.request_digest !== binding.request_digest)) invalid("existing evidence has a different original identity");
26
- // A plan frozen in saved evidence keeps the rules it was accepted under.
27
- if (existing?.body.plan_digest !== planDigest) validateNewHardwareWebPlan(input);
26
+ // A plan frozen in saved evidence keeps the rules it was accepted under; an input without
27
+ // plan_rules comes from a run pinned to a pre-P2-05.02 definition and keeps its old rules.
28
+ if (input.plan_rules === currentPlanRules && existing?.body.plan_digest !== planDigest) validateNewHardwareWebPlan(input);
28
29
  if (!existing || existing.body.protocol === chainProtocol) return searchProviderChain({ input, source, existing: storedExisting, env, readResource, fetchImpl, ports, checkpoint, readBlob, writeBlob });
29
30
  if (existing && (existing.body.provider !== provider || existing.body.protocol !== protocol)) throw failure("HARDWARE_WEB_RECONCILIATION_REQUIRED", "retain the original provider evidence; provider migration cannot reissue its invocation");
30
31
  validateHardwareWebIntegrity({ original: input, source, saved: storedExisting, readBlob, allowPartial: true, code: "HARDWARE_WEB_RECONCILIATION_REQUIRED" });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "abelien-hardware-selection",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "本地硬件选型:真实 TOS 召回、Agent 证据判断与受信资源校验;不是数据库兼容性求解器",
5
5
  "resources": ["hardware-selection"],
6
6
  "requires": ["abelien-harness", "abelien-hardware"],
7
7
  "capabilities": {
8
8
  "hardware.select": {
9
- "description": "回读同需求的真实召回资源,验证 Agent 选择的型号、数量、逐约束引用与兼容评估(电源与接口可由可信电气数据确定性判定,结构/散热/软件只提示),保存选型、可选尺寸占用摘要与兼容报告;未知不通过门禁",
9
+ "description": "回读同需求的真实召回资源,验证 Agent 选择的型号、数量、逐约束引用与兼容评估(电源与接口可由可信电气数据确定性判定,其未知仍阻断;结构/散热/软件只提示并列入 advisories),保存选型、可选尺寸占用摘要与兼容报告",
10
10
  "adapter": {
11
11
  "type": "module",
12
12
  "path": "scripts/select.mjs",
@@ -6,7 +6,7 @@ load-guide 的各层响应均附 runtimeBoundary 提示当前执行范围;只
6
6
 
7
7
  ## 请求和来源
8
8
 
9
- 请求仅含 requirements、slots、recall_id、selection_proposal。requirements 原样保留 clarify 的 context/reaction/limits;槽位带 slot_id/function/quantity/category_path[3]/hard_constraints 对象。recall_id 必须指向调用 workdir 内真实 hardware-recall,校验器通过受信 readResource 前后读回,比对完整需求/槽位 request_digest。不能提交召回正文替代真实来源。旧召回没有 request_digest 时须重新检索,不得修改原资源。
9
+ 请求仅含 requirements、slots、recall_id、selection_proposal。requirements 原样保留 clarify 的 context/reaction/limits;槽位带 slot_id/function/quantity/category_path[3]/hard_constraints 对象,可选 fit_envelope(见下文「结构化尺寸与正式门禁」)。recall_id 必须指向调用 workdir 内真实 hardware-recall,校验器通过受信 readResource 前后读回,比对完整需求/槽位 request_digest。不能提交召回正文替代真实来源。旧召回没有 request_digest 时须重新检索,不得修改原资源。
10
10
 
11
11
  选型前校验全部需求条目:context 必须带非空 text 和 source=user|system;reaction 与每一项 limits 都必须是含唯一 id、非空 text 和上述 source 的对象,ID 在两组之间也不得重复。每项 limit 的 strength 必须是 hard|preference;null、数值、缺归属或拼错 strength 均明确以 VALIDATION 拒绝,不可在筛选 hard 时静默消失。合法的系统假设/偏好原样保留;未解决证据仍保存在 unknowns 并返回 needs_confirmation。
12
12
 
@@ -20,7 +20,7 @@ proposal 包含 status=verified|needs_confirmation、plan_mode=assemble_new|adap
20
20
  - quantity 显式给出且等于槽位数量,不默认补 1;每槽位最多一个型号。
21
21
  - selection_reason 非空;wiki_evidence 为完整 Wiki 中至少 8 字符的原文引用数组,不是 ID、ETag 或自由改写摘要。
22
22
  - constraint_checks 逐项包含 constraint_id、status=verified|unknown|incompatible、reason、evidence:[{component_id,quote}]。ID 覆盖 function、slot:<hard_constraints 的键>、limit:<每项 hard limit.id>。缺关键证据仍 unknown。
23
- - 多型号须为 power、interfaces 提供 compatibility_checks(dimension/status/reason/evidence,每个所选型号都有原文证据),除非可信电气数据已确定性通过;mechanical、thermal、software 可选填写,只作提示。见下文“电气兼容与兼容报告”。
23
+ - 多型号须为 power、interfaces 提供 compatibility_checks(dimension/status/reason/evidence,每个所选型号都有原文证据),除非可信电气数据已确定性通过;mechanical、thermal、software 可选填写,只作提示,未填有效引用的列入可选 advisories。见下文“电气兼容与兼容报告”。
24
24
 
25
25
  constraint_checks 非数组、ID 重复或不属于当前槽位时仍以 VALIDATION 拒绝。error.message 列明 slot_id、expected(当前槽位的 function / slot 键与全局 hard limit ID)、received(完整原顺序,非数组为 null)、unexpected(去重、首次出现顺序)与 duplicate(去重、首次重复顺序),供既有 Web 预检 issues 原样反馈并修正。其他槽位具有某个 hard_constraints 键,不使它成为当前槽位的有效 ID;例如 desk-sensor 的 slot:no-stand 不能用于缺少该键的 weather-display。只缺少某项检查仍走 CONSTRAINT_UNVERIFIED、needs_confirmation,不因诊断改为结构拒绝;Wiki 归属、incompatible 和门禁语义不变。
26
26
 
@@ -61,7 +61,9 @@ V2 hardware evidence uses fixed prismer_then_volc ordered primary/fallback recei
61
61
 
62
62
  limits 可附 envelope;严格形状与来源规则见 [索引尺寸契约](../../abelien-hardware/references/search-index-format.md)。hardware.search 在分类所有索引记录上先筛尺寸再 Top-K,未知和估算保留。hardware.select 读取冻结 recall 并核对 request_digest、正文 SHA-256 后,复用纯判定器重算,忽略模型伪造的尺寸、来源、名称和 dimension_check。incompatible 以 VALIDATION 拒绝所选件,必须选其他存活候选;dimension_check.decision_required=true(用户 hard 上限+measured/datasheet 尺寸,且轴对齐装法全部失败,仍不足以证明放不下)时加入 DIMENSION_DECISION_REQUIRED 并返回 needs_confirmation,模型引用、确认或机械 verified 不能覆盖。unknown、估算尺寸、system/preference 上限或估算壁厚下的 risk 只随 selected_models.dimension_check 提示,不进入 unknowns,也不单独阻止 verified;尺寸限制仍须经原 constraint_checks 的 Wiki 证据门。
63
63
 
64
- 没有 envelope 时旧流程仍可在原 Wiki 证据范围 verified,但 selected_models.dimension_check 始终显式为 unknown(PRODUCT_ENVELOPE_MISSING),不能宣传尺寸检查通过。clear 也只表示单件粗筛;device_tested 仍 false。hardware.finalize 仍只返回 proposed/needs_confirmation 的提案,不创建硬件正式资源。
64
+ 槽位可附 `fit_envelope: {envelope, basis: locked|inferred, zone?}`,envelope 与 limits 的 envelope 同一校验,zone 为短标识;字段不合法以 VALIDATION 拒绝。它只对本槽位追加一个容器,原因的 `limit_id` 为 `fit:<slot_id>`,有 zone 时同时带 `zone`。locked 的权限与 user+hard 上限相同(可 incompatible、可 decision_required),inferred 与 system/preference 上限相同(只 risk)。它属于结构输入,不进入 constraint_checks 的 expected ID,由判定器确定性复核;槽位已计入 request_digest,改分区须重新检索。只有 fit_envelope、没有产品 envelope 时也做尺寸检查。
65
+
66
+ 没有 envelope 时旧流程仍可在原 Wiki 证据范围 verified,但 selected_models.dimension_check 始终显式为 unknown(PRODUCT_ENVELOPE_MISSING;槽位有 fit_envelope 时除外),不能宣传尺寸检查通过。clear 也只表示单件粗筛;device_tested 仍 false。hardware.finalize 仍只返回 proposed/needs_confirmation 的提案,不创建硬件正式资源。
65
67
 
66
68
  每个型号额外携带可信候选 title 派生的 name、dimensions_mm、dimension_basis、dimension_geometry、dimension_source 和 dimension_check;interfaces 只在候选存在时透传。bom_item_id 为 `selection-line-<24hex>`,以 slot_id/component_id 派生的稳定选型行身份,不冒称数据库 BOM 主键,不使用模型提供的 ID。
67
69
 
@@ -71,17 +73,23 @@ limits 可附 envelope;严格形状与来源规则见 [索引尺寸契约](../
71
73
 
72
74
  多型号选型由 `electrical.mjs` 按星形拓扑做确定性电气检查:选中的唯一主控(`compute-control/controller-board|controller-module`)为中心,`power-energy` 件给主控供电,主控电压轨与已选电源给外设供电,外设所需总线由主控提供。没有主控或主控不止一个时拓扑记为 not_star,全部未知。
73
75
 
74
- 只有 `electrical.basis` 为 measured / datasheet、且通过 `electrical_source` 源绑定的记录能给出确定结论:
76
+ 只有 `electrical.basis` 为 measured / datasheet、且通过 `electrical_source` 源绑定的记录能给出确定结论。数据没记录不等于不需要或没有:缺失一律记 unknown,unknown 永不升级为 pass;星形假设本身不是证明,因此 v1 不产生 fail,不以电气检查硬拒绝(VALIDATION)。
75
77
 
76
78
  | 结果 | 含义 | 门禁 |
77
79
  | --- | --- | --- |
78
- | pass | 全部相关件可信且规则全部通过 | power / interfaces 视为已验证,不再要求逐件原文引用 |
79
- | fail | 总线类型缺失或数量不足(I2C / SPI / CAN 是共享总线,主控有即可;UART / USB / I2S / PWM / GPIO / ADC 按数量累加) | finalize 与 select 以 VALIDATION 拒绝,须替换一侧 |
80
- | gap | 可信数据下没有电压轨落在外设或主控的输入范围内 | 记 `ELECTRICAL_SUPPLY_GAP`,返回 needs_confirmation;须补电源或稳压件,不拒绝外设 |
81
- | risk | 电压只部分重叠、连接器不同、逻辑电平不同且未选电平转换件 | 不确定性判定,回到原文引用路径 |
82
- | unknown | 数据缺失、未源绑定或非星形 | 回到原文引用路径 |
80
+ | pass | 全部相关件可信,且每条规则都有完整数据并通过:电压在输入范围内、电流有记录且不超额、逻辑电平有记录且一致(或由可信电平转换件化解)、总线足够 | power / interfaces 视为已验证,不再要求逐件原文引用 |
81
+ | gap | 可信数据下没有电压轨落在外设或主控的输入范围内(`NO_SUPPLY_RAIL_FOR_INPUT`) | 记 `ELECTRICAL_SUPPLY_GAP`,返回 needs_confirmation;须补电源或稳压件,不拒绝外设 |
82
+ | risk | 电压只部分重叠(`RAIL_PARTIALLY_OUTSIDE_INPUT`)、连接器不同(`CONNECTOR_MISMATCH`)、电压轨超载(`RAIL_CURRENT_EXCEEDED`)、逻辑电平不同且未选电平转换件(`LOGIC_LEVEL_MISMATCH`)、同一 SPI 上外设多于主控 SPI 数(`SPI_CHIP_SELECT_UNVERIFIED`)、主控缺该总线类型或数量不足(`BUS_TYPE_MISSING` / `BUS_COUNT_SHORT`) | 不确定性判定,回到原文引用路径;总线类型缺失或数量不足另记 `ELECTRICAL_BUS_UNCONFIRMED` 并返回 needs_confirmation,引用也不能覆盖 |
83
+ | unknown | 数据缺失、未源绑定或非星形:`UNTRUSTED_OR_MISSING_DATA`、`UNTRUSTED_SOURCE_MAY_SUPPLY`、`NO_DECLARED_INPUT`(可信件 power_inputs 为空)、`CURRENT_UNRECORDED`(电压轨或任一受电方缺 max_current_a)、`CONTROLLER_SUPPLY_OUTSIDE_SELECTION`(未选电源件,主控供电在选型之外)、`LOGIC_LEVEL_UNRECORDED`(任一侧缺 logic_level_v)、`LEVEL_SHIFTER_UNTRUSTED`(电平不符且所选转换件不可信)、`CONTROLLER_BUSES_UNRECORDED`(主控未记录任何 controller 角色总线) | 回到原文引用路径 |
84
+
85
+ 电流按以下保守、可解释的方式检查(v1 没有显式的供电分配):输入的 `max_current_a` 指该输入端的最大取用电流(含经本件输出转供的负载),输出的 `max_current_a` 指该电压轨的最大供给电流。每个受电方从电压匹配的可信电压轨中选记录电流最大的一条(无电流记录的排最后,同等时按出现顺序);一条电压轨的负载是分配到它的全部受电方 `max_current_a × quantity` 之和,数量大于 1 的电源件的同一电压轨只按一份供给计。已记录负载之和超过供给即 `RAIL_CURRENT_EXCEEDED`;该电压轨或其任一受电方缺电流记录即 `CURRENT_UNRECORDED`。这种分配可能把本可另行分摊的负载判为风险,只会多要求确认,不会误判通过。
86
+
87
+ v1 的已知局限:不检查 I2C 地址冲突;SPI 只按“外设数多于主控 SPI 数”提示片选风险,不核对片选引脚数;电平转换件、扩展板与驱动板自身的供电和下游总线不建模(扩展板后面挂的外设会被算到主控头上,所以总线不足只记风险)。
83
88
 
84
89
  mechanical、thermal、software 只作提示,不进入 unknowns,也不阻止 verified;结构可行性由尺寸粗筛和 CAD 检查负责。任何维度只要模型明确给出 status=incompatible,都记 `COMPATIBILITY_INCOMPATIBLE` 并阻塞。
85
90
 
86
- 多型号时输出增加可选根字段 `compatibility_report`:`method`、`dimensions`(每维 gate=deterministic|evidence|unverified|advisory、model_status、model_verified,power / interfaces 另有 electrical 结论)和 `electrical`(拓扑、主控与逐条检查)。它是选型依据的说明,不代表实测或制造可行;接入方须显式接收这一可选字段。所选型号原样携带候选的 `electrical` 与 `electrical_source`,不采用模型自填。
91
+ 多型号时输出增加可选根字段 `compatibility_report`:`method`、`dimensions`(每维 gate=deterministic|evidence|unverified|advisory、model_status、model_verified,power / interfaces 另有 electrical 结论)和 `electrical`(拓扑、主控与逐条检查;供电检查另带 `current`:status=within|exceeded|unrecorded、draw_a、rail_load_a、capacity_a)。它是选型依据的说明,不代表实测或制造可行;接入方须显式接收这一可选字段。所选型号原样携带候选的 `electrical` 与 `electrical_source`,不采用模型自填。
92
+
93
+ 多型号时若有提示,输出再增加可选根字段 `advisories`(没有提示时省略):每个 gate=advisory 且模型未给出有效 verified 引用的维度一条 `{code:"COMPATIBILITY_ADVISORY_UNVERIFIED", dimension, model_status}`;power / interfaces 电气结论为 risk、却凭模型引用以 gate=evidence 通过时一条 `{code:"ELECTRICAL_RISK_OVERRIDDEN_BY_EVIDENCE", dimension, electrical:"risk"}`。advisories 只供展示与复核,不影响 status,也不进入 unknowns;接入方须显式接收这一可选字段。
87
94
 
95
+ 已保存的选型结果按保存时规则原样只读;旧运行重新执行 hardware.select 时按上述新规则重算,可能由 verified 变为 needs_confirmation,也可能由原先的 VALIDATION 拒绝变为 needs_confirmation。
@@ -58,7 +58,7 @@ export async function selectHardware({ input, readResource, readBlob }) {
58
58
  if (!candidate || canonical(candidate.category_path) !== canonical(slot.category_path)
59
59
  || !text(candidate.content) || !/^[a-f0-9]{64}$/.test(candidate.wiki?.sha256 ?? "")) invalid("candidate needs complete matching Wiki provenance");
60
60
  if (createHash("sha256").update(candidate.content).digest("hex") !== candidate.wiki.sha256) invalid("candidate content does not match frozen Wiki SHA-256");
61
- const dimensionCheck = evaluateDimensions(candidate, requirements);
61
+ const dimensionCheck = evaluateDimensions(candidate, requirements, slot);
62
62
  model.dimension_check = dimensionCheck;
63
63
  if (dimensionCheck.status === "incompatible") invalid("selected candidate is dimensionally incompatible");
64
64
  // Only a user hard limit with trusted sizes asks the user to decide; unknown and other risk stay advisory on dimension_check.
@@ -103,13 +103,17 @@ export async function selectHardware({ input, readResource, readBlob }) {
103
103
  const compatibility = proposal.compatibility_checks ?? [];
104
104
  if (!Array.isArray(compatibility)) invalid("compatibility_checks must be an array");
105
105
  // Power/interfaces: trusted deterministic pass, or quoted model evidence. Mechanical/thermal/software are advisory
106
- // (size screening and CAD own mechanical fit); an explicit model "incompatible" still blocks every dimension.
106
+ // (size screening and CAD own mechanical fit): unquoted ones are listed in advisories, not unknowns, so they never block verified.
107
+ // An explicit model "incompatible" still blocks every dimension.
107
108
  let compatibilityReport;
109
+ const advisories = [];
108
110
  if (models.size > 1) {
109
111
  const electrical = evaluateElectrical(normalized.selected_models);
110
- if (electrical.checks.some(check => check.status === "fail")) invalid("selected components are electrically incompatible");
111
112
  const gaps = electrical.checks.filter(check => check.status === "gap");
112
113
  if (gaps.length) add("ELECTRICAL_SUPPLY_GAP", { checks: gaps });
114
+ // Bus shortfalls under the star assumption are not proof (expansion and driver boards are not modelled): confirm, never reject.
115
+ const buses = electrical.checks.filter(check => check.rule === "bus_availability" && check.status === "risk");
116
+ if (buses.length) add("ELECTRICAL_BUS_UNCONFIRMED", { checks: buses });
113
117
  const dimensions = {};
114
118
  for (const dimension of ["power", "interfaces", "mechanical", "thermal", "software"]) {
115
119
  const checks = compatibility.filter(item => item?.dimension === dimension);
@@ -119,9 +123,12 @@ export async function selectHardware({ input, readResource, readBlob }) {
119
123
  const deterministic = electrical.dimensions[dimension];
120
124
  const gate = ["mechanical", "thermal", "software"].includes(dimension) ? "advisory"
121
125
  : deterministic === "pass" ? "deterministic" : modelVerified ? "evidence" : "unverified";
126
+ const modelStatus = checks.length === 1 ? String(checks[0].status) : checks.length ? "ambiguous" : "missing";
122
127
  if (gate === "unverified") add("COMPATIBILITY_UNVERIFIED", { dimension, assessments: checks, electrical: deterministic });
123
128
  if (checks.some(item => item?.status === "incompatible")) add("COMPATIBILITY_INCOMPATIBLE", { dimension, assessments: checks });
124
- dimensions[dimension] = { gate, model_status: checks.length === 1 ? String(checks[0].status) : checks.length ? "ambiguous" : "missing",
129
+ if (gate === "advisory" && !modelVerified) advisories.push({ code: "COMPATIBILITY_ADVISORY_UNVERIFIED", dimension, model_status: modelStatus });
130
+ if (gate === "evidence" && deterministic === "risk") advisories.push({ code: "ELECTRICAL_RISK_OVERRIDDEN_BY_EVIDENCE", dimension, electrical: "risk" });
131
+ dimensions[dimension] = { gate, model_status: modelStatus,
125
132
  model_verified: modelVerified, ...(deterministic !== undefined ? { electrical: deterministic } : {}) };
126
133
  }
127
134
  compatibilityReport = { method: "compatibility-gate-v1", dimensions, electrical };
@@ -142,6 +149,7 @@ export async function selectHardware({ input, readResource, readBlob }) {
142
149
  const hash = digest({ input, source_digest: digest(source.body) });
143
150
  return { ...normalized, id: `selection-${hash.slice(0, 16)}`, revision: `r-${hash.slice(0, 12)}`,
144
151
  status, coverage_ledger: coverage, remaining_slots: remaining, unknowns,
145
- ...(compatibilityReport ? { compatibility_report: compatibilityReport } : {}) };
152
+ ...(compatibilityReport ? { compatibility_report: compatibilityReport } : {}),
153
+ ...(advisories.length ? { advisories } : {}) };
146
154
  }
147
155
  export const run = selectHardware;
@@ -178,7 +178,7 @@ commands:
178
178
  [--limit <n>] [--scan-limit <n>] [--without-content]
179
179
  resource put --kind <k> --id <localId> --body '<json>'
180
180
  resource get <id> [--page <pageId>] | list [--kind <k>] | delete <id> | locate <query>
181
- capability list | run <name> [--input '<json>'] [--resource <id>]
181
+ capability list | run <name> [--input '<json>' | --input-file <path>] [--resource <id>]
182
182
  workflow list | run <name> [--input '<json>'] | status <runId> [--httl]
183
183
  | advance <runId> --result '<json>' | --httl '<doc>' | --httl-file <path>
184
184
  | resume <runId> | cancel <runId>
@@ -227,6 +227,22 @@ function parseJsonFlag(value, name) {
227
227
  try { return JSON.parse(value); } catch { throw fail("USAGE", `--${name} must be valid JSON`); }
228
228
  }
229
229
 
230
+ // Large inputs (e.g. a reference image as base64) exceed argv limits. The file must be a regular
231
+ // JSON file inside the workdir; symlinks or paths that resolve outside it are refused.
232
+ const INPUT_FILE_MAX_BYTES = 32 * 1024 * 1024;
233
+ function readInputFile(workdir, p) {
234
+ if (typeof p !== "string" || !p) throw fail("USAGE", "--input-file requires a path");
235
+ const root = fs.realpathSync(path.resolve(workdir));
236
+ const target = path.resolve(root, p);
237
+ let real;
238
+ try { real = fs.realpathSync(target); } catch { throw fail("USAGE", `file not found: ${p}`); }
239
+ if (real !== root && !real.startsWith(root + path.sep)) throw fail("USAGE", "--input-file must be inside the workdir");
240
+ const stat = fs.statSync(real);
241
+ if (!stat.isFile()) throw fail("USAGE", "--input-file must be a regular file");
242
+ if (stat.size > INPUT_FILE_MAX_BYTES) throw fail("USAGE", `--input-file exceeds ${INPUT_FILE_MAX_BYTES} bytes`);
243
+ return parseJsonFlag(fs.readFileSync(real, "utf8"), "input-file");
244
+ }
245
+
230
246
  function requireFlagFile(p) {
231
247
  if (!p) throw fail("USAGE", "--httl-file requires a path");
232
248
  if (!fs.existsSync(p)) throw fail("USAGE", `file not found: ${p}`);
@@ -692,7 +708,7 @@ function capabilityCmd(pos, flags, deps) {
692
708
  { store, domains, env: deps.env, fetchImpl: deps.fetchImpl, readEvidence: deps.readEvidence, modulePorts: deps.modulePorts },
693
709
  name,
694
710
  {
695
- input: flags.input ? parseJsonFlag(flags.input, "input") : {},
711
+ input: capabilityInput(flags, deps.workdir),
696
712
  ...(flags.resource ? { resource: flags.resource } : {}),
697
713
  }
698
714
  );
@@ -700,6 +716,12 @@ function capabilityCmd(pos, flags, deps) {
700
716
  throw fail("USAGE", `unknown capability subcommand: ${pos[0]}`);
701
717
  }
702
718
 
719
+ function capabilityInput(flags, workdir) {
720
+ if (flags.input !== undefined && flags["input-file"] !== undefined) throw fail("USAGE", "use either --input or --input-file, not both");
721
+ if (flags["input-file"] !== undefined) return readInputFile(workdir, flags["input-file"]);
722
+ return flags.input ? parseJsonFlag(flags.input, "input") : {};
723
+ }
724
+
703
725
  function positiveIntFlag(value, name, fallback) {
704
726
  if (value === undefined) return fallback;
705
727
  const parsed = Number(value);
@@ -0,0 +1,20 @@
1
+ // 内核联网原语的公共工具(P2-13.03)。不含任何域的错误码:各域包在自己的模块里绑定,
2
+ // 例如 hardware 绑定 HARDWARE_WEB_*,以保持已发布行为与错误码逐字不变。例外:Volc 凭据变量名
3
+ // WEB_SEARCH_API_KEY(volc.mjs)与默认 DoH 地址(public-read.mjs)写在内核里。
4
+ import { createHash } from "node:crypto";
5
+
6
+ export const record = value => value && typeof value === "object" && !Array.isArray(value);
7
+ export const text = value => typeof value === "string" && value.trim().length > 0;
8
+ export function failure(code, message, details) { return Object.assign(new Error(message), { code, ...(details ? { details } : {}) }); }
9
+ export function invalid(message) { throw failure("VALIDATION", message); }
10
+ export function onlyKeys(value, keys, label) { if (!record(value) || Object.keys(value).some(key => !keys.includes(key))) invalid(`${label} has unsupported fields`); }
11
+ export const sha256 = bytes => createHash("sha256").update(bytes).digest("hex");
12
+ export const canonical = value => value === null || typeof value !== "object" ? JSON.stringify(value)
13
+ : Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
14
+ : `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
15
+ export const digest = value => createHash("sha256").update(canonical(value)).digest("hex");
16
+ export async function timed(operation, timeoutMs, code, message = "network request timed out") {
17
+ const controller = new AbortController(); let timer;
18
+ try { return await Promise.race([operation(controller.signal), new Promise((_, reject) => { timer = setTimeout(() => { reject(failure(code, message)); controller.abort(); }, timeoutMs); })]); }
19
+ finally { clearTimeout(timer); }
20
+ }
@@ -0,0 +1,168 @@
1
+ // 公开 HTTPS 安全直读(P2-13.03 自 hardware 移入内核):只读公网 HTTPS、拒绝凭据与内网地址、
2
+ // 每跳重新解析并钉住地址、限制重定向,可选固定 DoH。错误码、User-Agent 与 Accept 由调用方绑定,
3
+ // 本模块不含任何域的码;hardware 绑定后行为与错误码逐字不变。
4
+ import https from "node:https";
5
+ import dns from "node:dns/promises";
6
+ import { isIP } from "node:net";
7
+ import { failure, sha256, timed } from "./common.mjs";
8
+
9
+ const networkError = error => ["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EPIPE", "ENETUNREACH", "EHOSTUNREACH", "EAI_AGAIN", "ENOTFOUND", "ENODATA", "ESERVFAIL", "EREFUSED"].includes(error?.code);
10
+ const STAGES = ["transport", "dns", "access", "redirect", "timeout"];
11
+
12
+ export function publicAddress(address) {
13
+ if (typeof address !== "string") return false;
14
+ if (isIP(address) === 4) {
15
+ const value = address.split(".").reduce((out, part) => (out * 256 + Number(part)) >>> 0, 0);
16
+ const ranges = [["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4]];
17
+ return !ranges.some(([network, bits]) => {
18
+ const base = network.split(".").reduce((out, part) => (out * 256 + Number(part)) >>> 0, 0), mask = (0xffffffff << (32 - bits)) >>> 0;
19
+ return (value & mask) === (base & mask);
20
+ });
21
+ }
22
+ if (isIP(address) === 6) {
23
+ const normalized = new URL(`https://[${address}]/`).hostname.slice(1, -1);
24
+ return /^[23][0-9a-f]{3}:/.test(normalized) && !/^(2001:(:|[01][0-9a-f]?:|2[0-9a-f]?:|db8:)|2002:|3fff:)/.test(normalized);
25
+ }
26
+ return false;
27
+ }
28
+ export function decodeHtmlEntities(raw) {
29
+ return raw.replace(/&#(x[0-9a-f]+|[0-9]+);/gi, (_, n) => { const code = n[0].toLowerCase() === "x" ? parseInt(n.slice(1), 16) : Number(n); return code <= 0x10ffff ? String.fromCodePoint(code) : "�"; })
30
+ .replace(/&(amp|lt|gt|quot|apos|nbsp);/gi, (_, entity) => ({ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " })[entity.toLowerCase()]);
31
+ }
32
+ // Charset from the Content-Type header, else from <meta charset> / http-equiv in the first 2 KB; UTF-8 otherwise.
33
+ // Unknown labels fall back to UTF-8 rather than failing the read.
34
+ export function decodeBody(bytes, contentType = "") {
35
+ const header = /charset\s*=\s*["']?([\w.:-]+)/i.exec(contentType)?.[1];
36
+ const head = Buffer.from(bytes).subarray(0, 2048).toString("latin1");
37
+ const meta = /<meta[^>]+charset\s*=\s*["']?([\w.:-]+)/i.exec(head)?.[1];
38
+ const label = (header || meta || "utf-8").toLowerCase();
39
+ try { return new TextDecoder(label === "gb2312" ? "gbk" : label).decode(bytes); } catch { return Buffer.from(bytes).toString("utf8"); }
40
+ }
41
+ export function pageText(raw, contentType) {
42
+ if (!contentType.includes("html")) return raw;
43
+ return decodeHtmlEntities(raw.replace(/<(script|style|noscript)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, " ").replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
44
+ }
45
+
46
+ /**
47
+ * codes:transport / dns / access / redirect / timeout 为读取各阶段失败的公开码;
48
+ * url 为地址不合规;config 为 DNS 传输配置非法;readTimeout 为内部超时哨兵(转译成 timeout 阶段,不外泄)。
49
+ * decodeCharset:按响应头或页面 meta 声明的字符集解码正文(如 GBK);默认 false,即一律按 UTF-8 解码,
50
+ * hardware 保持默认,已保存证据的正文摘要不受影响。
51
+ */
52
+ export function createPublicReader({ codes, userAgent, accept, decodeCharset = false }) {
53
+ for (const key of [...STAGES, "url", "config", "readTimeout"]) if (typeof codes?.[key] !== "string" || !codes[key]) throw new Error(`public reader code ${key} required`);
54
+ if (typeof userAgent !== "string" || !userAgent || typeof accept !== "string" || !accept) throw new Error("public reader userAgent and accept required");
55
+ const publicReadFailureCodes = Object.freeze(Object.fromEntries(STAGES.map(stage => [stage, codes[stage]])));
56
+ const readFailures = new WeakMap();
57
+ const publicReadFailure = error => readFailures.get(error) ?? null;
58
+ function readFailure(stage) {
59
+ const code = publicReadFailureCodes[stage], error = failure(code, `public source ${stage} failed`);
60
+ readFailures.set(error, Object.freeze({ stage, code, failed_at: new Date().toISOString() })); return error;
61
+ }
62
+ async function readTimed(operation, timeoutMs) {
63
+ try { return await timed(operation, timeoutMs, codes.readTimeout); }
64
+ catch (error) { if (error?.code === codes.readTimeout) throw readFailure("timeout"); throw error; }
65
+ }
66
+ function normalizePublicSourceUrl(value) {
67
+ let url; try { url = new URL(value); } catch { throw failure(codes.url, "public source URL is invalid"); }
68
+ if (url.protocol !== "https:" || url.username || url.password || (url.port && url.port !== "443")
69
+ || !url.hostname.includes(".") || isIP(url.hostname.replace(/^\[|\]$/g, ""))
70
+ || /(^|\.)(localhost|local|internal|test|invalid)$/.test(url.hostname)) throw failure(codes.url, "only public HTTPS source URLs without credentials are allowed");
71
+ url.hash = ""; return url;
72
+ }
73
+ function pageRequest(url, { address, family, signal, headers = {}, maxBytes = Infinity }) {
74
+ return new Promise((resolve, reject) => {
75
+ const req = https.get(url, { signal, agent: false, servername: new URL(url).hostname, rejectUnauthorized: true, headers: { "user-agent": userAgent, accept, ...headers },
76
+ lookup: (_hostname, opts, callback) => callback(null, ...(opts.all ? [[{ address, family }]] : [address, family])) }, response => {
77
+ const chunks = []; let size = 0, truncated = false;
78
+ response.on("data", chunk => {
79
+ if (truncated) return;
80
+ size += chunk.length;
81
+ if (size > maxBytes) { truncated = true; response.destroy(); resolve({ status: response.statusCode, headers: response.headers, bytes: Buffer.concat(chunks), truncated: true }); return; }
82
+ chunks.push(chunk);
83
+ });
84
+ response.on("error", () => { if (!truncated) reject(readFailure("transport")); });
85
+ response.on("end", () => { if (!truncated) resolve({ status: response.statusCode, headers: response.headers, bytes: Buffer.concat(chunks) }); });
86
+ });
87
+ req.on("error", () => reject(readFailure("transport")));
88
+ });
89
+ }
90
+ async function dohLookup(hostname, signal, request) {
91
+ const answers = await Promise.all(["A", "AAAA"].map(async type => {
92
+ // Fixed trusted resolver and public bootstrap address; no unchecked system resolution.
93
+ const url = `https://cloudflare-dns.com/dns-query?type=${type}&name=${encodeURIComponent(hostname)}`;
94
+ let response;
95
+ try {
96
+ response = await request(url, { address: "1.1.1.1", family: 4, signal, headers: { accept: "application/dns-json" } });
97
+ } catch (error) { if (publicReadFailure(error) || networkError(error)) throw readFailure("dns"); throw error; }
98
+ let value;
99
+ try { value = JSON.parse(Buffer.from(response.bytes ?? []).toString("utf8")); }
100
+ catch (error) { if (error instanceof SyntaxError) throw failure(codes.dns, "trusted public DNS returned malformed JSON"); throw error; }
101
+ if (response.status !== 200 || value?.Status !== 0 || (value.Answer !== undefined && !Array.isArray(value.Answer))) throw readFailure("dns");
102
+ return (value.Answer ?? []).filter(item => [1, 28].includes(item?.type)).map(item => ({ address: item.data, family: item.type === 1 ? 4 : 6 }));
103
+ }));
104
+ return answers.flat();
105
+ }
106
+ // Validate the submitted URL only. A remote fetcher's own redirect behavior is outside
107
+ // this local DNS check; this does not assert that its source connection is pinned.
108
+ async function validatePublicSource(target, { lookup, dnsRequest = pageRequest, dnsTransport = "system", timeoutMs = 30000, checkUrl = () => {} } = {}) {
109
+ checkUrl(target);
110
+ const url = normalizePublicSourceUrl(target);
111
+ await readTimed(async signal => {
112
+ let addresses;
113
+ try { addresses = await (lookup ? lookup(url.hostname) : dnsTransport === "doh" ? dohLookup(url.hostname, signal, dnsRequest) : dns.lookup(url.hostname, { all: true, verbatim: true })); }
114
+ catch (error) { if (publicReadFailure(error) || networkError(error)) throw readFailure("dns"); throw error; }
115
+ if (!Array.isArray(addresses) || !addresses.length || addresses.some(item => !publicAddress(item?.address) || isIP(item.address) !== item.family)) throw failure(codes.url, "submitted source DNS includes non-public addresses");
116
+ }, timeoutMs);
117
+ return url.href;
118
+ }
119
+ // One pinned-address fetch loop shared by page and byte reads. Returns the final response and its provenance.
120
+ async function fetchPinned(target, { lookup, pageRequest: request = pageRequest, dnsRequest = pageRequest, dnsTransport = "system", timeoutMs = 30000, maxRedirects = 5, maxBytes, checkUrl = () => {} } = {}) {
121
+ checkUrl(target);
122
+ let url = normalizePublicSourceUrl(target); const redirects = [], connections = [], fetchedAt = new Date().toISOString();
123
+ if (!["system", "doh"].includes(dnsTransport)) throw failure(codes.config, "web DNS transport must be system or doh");
124
+ return readTimed(async signal => {
125
+ for (let n = 0; n <= maxRedirects; n++) {
126
+ let addresses;
127
+ try { addresses = await (lookup ? lookup(url.hostname) : dnsTransport === "doh" ? dohLookup(url.hostname, signal, dnsRequest) : dns.lookup(url.hostname, { all: true, verbatim: true })); }
128
+ catch (err) { if (publicReadFailure(err) || networkError(err)) throw readFailure("dns"); throw err; }
129
+ if (!Array.isArray(addresses) || !addresses.length || addresses.some(item => !publicAddress(item?.address) || isIP(item.address) !== item.family)) throw readFailure("access");
130
+ connections.push({ url: url.href, tls_hostname: url.hostname, addresses, address: addresses[0].address, family: addresses[0].family });
131
+ let response;
132
+ try { response = await request(url.href, { address: addresses[0].address, family: addresses[0].family, signal, ...(maxBytes === undefined ? {} : { maxBytes }) }); }
133
+ catch (error) { if (publicReadFailure(error)) throw error; if (networkError(error)) throw readFailure("transport"); throw error; }
134
+ const bytes = Buffer.from(response.bytes ?? []), headers = response.headers ?? {};
135
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
136
+ if (n === maxRedirects || !headers.location) throw readFailure("redirect");
137
+ let next;
138
+ try { next = new URL(headers.location, url); } catch (error) { if (error?.code === "ERR_INVALID_URL") throw readFailure("redirect"); throw error; }
139
+ try { next = normalizePublicSourceUrl(next.href); checkUrl(next.href); } catch (error) { if (error?.code === codes.url) throw readFailure("access"); throw error; }
140
+ redirects.push({ from: url.href, to: next.href }); url = next; continue;
141
+ }
142
+ return { response, bytes, headers, common: { url: normalizePublicSourceUrl(target).href, final_url: url.href, fetched_at: fetchedAt, redirects, connections, dns_transport: dnsTransport, http_status: response.status,
143
+ raw_sha256: sha256(bytes), raw_base64: bytes.toString("base64"), content_type: String(headers["content-type"] ?? "").toLowerCase() } };
144
+ }
145
+ }, timeoutMs);
146
+ }
147
+ async function fetchPublicPage(target, options = {}) {
148
+ const { response, bytes, common } = await fetchPinned(target, options);
149
+ const mime = common.content_type;
150
+ // Only callers that pass maxBytes can see truncation; hardware never does, so its results are unchanged.
151
+ if (response.truncated) return { ...common, status: "unavailable", reason: "too_large", text: null, text_sha256: null };
152
+ if (response.status < 200 || response.status >= 300) return { ...common, status: "unavailable", reason: "http_status", text: null, text_sha256: null };
153
+ if (mime.includes("pdf") || bytes.subarray(0, 5).toString() === "%PDF-") return { ...common, status: "unavailable", reason: "pdf_parser_unavailable", text: null, text_sha256: null };
154
+ if (!(mime.startsWith("text/") || mime.includes("xhtml"))) return { ...common, status: "unavailable", reason: "unsupported_content_type", text: null, text_sha256: null };
155
+ const text = pageText(decodeCharset ? decodeBody(bytes, mime) : bytes.toString("utf8"), mime);
156
+ return { ...common, status: text.trim() ? "read" : "unavailable", reason: text.trim() ? null : "empty_page", text, text_sha256: sha256(text) };
157
+ }
158
+ // Raw bytes (for example a product image) with the same address pinning; never decodes or interprets them.
159
+ async function fetchPublicBytes(target, { maxBytes, contentTypes = [], ...options } = {}) {
160
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error("fetchPublicBytes requires a positive integer maxBytes");
161
+ const { response, common } = await fetchPinned(target, { ...options, maxBytes });
162
+ if (response.truncated) return { ...common, status: "unavailable", reason: "too_large" };
163
+ if (response.status < 200 || response.status >= 300) return { ...common, status: "unavailable", reason: "http_status" };
164
+ if (contentTypes.length && !contentTypes.some(type => common.content_type.startsWith(type))) return { ...common, status: "unavailable", reason: "unsupported_content_type" };
165
+ return { ...common, status: "read", reason: null };
166
+ }
167
+ return { publicReadFailureCodes, publicReadFailure, normalizePublicSourceUrl, validatePublicSource, fetchPublicPage, fetchPublicBytes };
168
+ }