diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index d11909e968..5759fb9b84 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -856,6 +856,11 @@ known pointer does not require another index round trip. Batch within the combin output budget, avoid overlapping parent/child selections, and inspect a missing pointer's parent before retrying the rejected batch. These are caller guidelines; selection validation, required reads and authority checks remain unchanged. +Selection errors identify the original `--select` argument and, for a failed +lookup, its token position (both 1-based). Sorting and duplicate elimination do +not renumber arguments. Inspect the resolved prefix before that token in the +same capture; malformed pointers need syntax repair first. Diagnostics never +echo the pointer or task values, and no part of a rejected batch is delivered. The `context_capture_index_v1` view shares the digest-bound pagination contract. An expanded index includes its `list_depth`; `next_command` preserves the depth, @@ -895,6 +900,10 @@ pointer 从原始摘要绑定的 capture 批量读取详情。提供已有 `--ca `--select`,已知路径无须多查一轮目录。按合计输出预算分批,避免同时选择父节点和 子节点;路径缺失时先查其父节点,再重试被拒绝的批次。这些是调用建议,不改变 选择校验、必读要求或权限检查。 +选择错误会标明原始 `--select` 参数序号;查找失败时还会标明 token 序号,均从 1 +开始。排序和去重不改变参数编号。在同一 capture 中检查失败 token 前已解析的 +路径前缀;语法错误需先修正 pointer。错误信息不回显路径或任务正文,被拒绝的 +批次不会返回部分内容。 `context_capture_index_v1` 沿用双摘要分页;展开的目录包含 `list_depth`, `next_command` 保留深度、路径和两个摘要。更改深度会拒绝旧视图的续读,目录 cursor diff --git a/loopx/control_plane/quota/context_selection.ts b/loopx/control_plane/quota/context_selection.ts index 917554f731..379bb01670 100644 --- a/loopx/control_plane/quota/context_selection.ts +++ b/loopx/control_plane/quota/context_selection.ts @@ -12,23 +12,30 @@ export function capturedContextSelection(request: JsonObject): JsonObject { if (typeof depth !== "number" || !Number.isInteger(depth) || depth < 1 || depth > 8 || request.list_depth !== undefined && request.list_fields !== true) throw new EffectRuntimeRequestError("list-depth requires --list and an integer from 1 to 8"); - const pointers = [...new Set(selectors as string[])].sort(); + // Keep caller positions before canonical sorting/deduplication. Diagnostics + // identify a failed address without echoing possibly sensitive keys or values. + const argumentsByPointer = new Map(); + (selectors as string[]).forEach((pointer, index) => { + if (!argumentsByPointer.has(pointer)) argumentsByPointer.set(pointer, index + 1); + }); + const pointers = [...argumentsByPointer.keys()].sort(); const selections = pointers.map(pointer => { + const argument = `--select argument ${argumentsByPointer.get(pointer)}`; if (pointer !== "" && !pointer.startsWith("/") || /~(?:[^01]|$)/.test(pointer)) - throw new EffectRuntimeRequestError("context selection requires RFC 6901 pointers"); + throw new EffectRuntimeRequestError(`context selection requires RFC 6901 pointers (${argument}; positions are 1-based)`); const tokens = pointer === "" ? [] : pointer.slice(1).split("/").map(token => token.replace(/~1/g, "/").replace(/~0/g, "~")); const path: (string | number)[] = []; let value: unknown = context; for (const token of tokens) { if (Array.isArray(value)) { if (!/^(0|[1-9][0-9]*)$/.test(token) || !Number.isSafeInteger(Number(token)) || Number(token) >= value.length) - throw new EffectRuntimeRequestError("context selection array index is missing or invalid"); + throw new EffectRuntimeRequestError(`context selection array index is missing or invalid (${argument}, token ${path.length + 1}; positions are 1-based)`); path.push(Number(token)); value = value[Number(token)]; } else if (value !== null && typeof value === "object" && Object.hasOwn(value, token)) { path.push(token); value = (value as JsonObject)[token]; - } else throw new EffectRuntimeRequestError("context selection pointer does not exist"); + } else throw new EffectRuntimeRequestError(`context selection pointer does not exist (${argument}, token ${path.length + 1}; positions are 1-based)`); } // Return the verified address, not a JS-number-coerced copy of the value. // The file adapter retains original JSON values and owns byte serialization. diff --git a/tests/control_plane/test_context_capture_selection.py b/tests/control_plane/test_context_capture_selection.py index 942c7361c2..1e17306737 100644 --- a/tests/control_plane/test_context_capture_selection.py +++ b/tests/control_plane/test_context_capture_selection.py @@ -263,3 +263,16 @@ def test_cli_rejects_invalid_depth_before_authority(tmp_path, options): assert code != 0 and page["ok"] is False assert "list-depth" in page["reason"] assert not registry.exists() and not runtime.exists() + + +@pytest.mark.parametrize("pointer,token", [("/rows/0/missing", 3), ("/rows/9", 2)]) +def test_cli_missing_batch_pointer_identifies_original_argument_without_source_leak(tmp_path, pointer, token): + path, raw, _ = fixture_capture(tmp_path) + registry, runtime = tmp_path / "absent.json", tmp_path / "absent-runtime" + code, result = _run_cli(registry, runtime, "quota", "context-read", + "--capture-file", str(path), "--capture-sha256", hashlib.sha256(raw).hexdigest(), + "--select", "/rows", "--select", "/rows", "--select", pointer, cwd=tmp_path) + assert code != 0 and result["ok"] is False and "content" not in result + assert f"--select argument 3, token {token}" in result["reason"] + assert pointer not in result["reason"] and "stop if unavailable" not in json.dumps(result) + assert path.read_bytes() == raw and not registry.exists() and not runtime.exists() diff --git a/tests/control_plane_ts/context_selection.test.ts b/tests/control_plane_ts/context_selection.test.ts index 891dd0c4c4..c8a82133f2 100644 --- a/tests/control_plane_ts/context_selection.test.ts +++ b/tests/control_plane_ts/context_selection.test.ts @@ -55,3 +55,18 @@ test("bounded depth discovers nested addresses in one batch without values", () assert.throws(() => evaluateTurnEnvelope({...request, list_depth})); assert.throws(() => evaluateTurnEnvelope({...request, list_fields: false, list_depth: 2})); }); + + +test("selection failures identify original argument and token without echoing content", () => { + const context = {z: true, rows: [{stop: "private-value"}]}; + for (const [pointer, token] of [["/rows/0/missing", 3], ["/rows/9", 2], ["/rows/01", 2], ["/rows/0/stop/x", 4]] as const) { + assert.throws(() => select(context, ["/z", "/z", pointer]), (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, new RegExp(`--select argument 3, token ${token}`)); + assert.ok(!error.message.includes("private-value") && !error.message.includes(pointer)); + assert.ok(error.message.length < 240); + return true; + }); + } + assert.throws(() => select(context, ["/z", "secret-".repeat(1000)]), /--select argument 2/); +});