Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/reference/required-work-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -895,6 +900,10 @@ pointer 从原始摘要绑定的 capture 批量读取详情。提供已有 `--ca
`--select`,已知路径无须多查一轮目录。按合计输出预算分批,避免同时选择父节点和
子节点;路径缺失时先查其父节点,再重试被拒绝的批次。这些是调用建议,不改变
选择校验、必读要求或权限检查。
选择错误会标明原始 `--select` 参数序号;查找失败时还会标明 token 序号,均从 1
开始。排序和去重不改变参数编号。在同一 capture 中检查失败 token 前已解析的
路径前缀;语法错误需先修正 pointer。错误信息不回显路径或任务正文,被拒绝的
批次不会返回部分内容。

`context_capture_index_v1` 沿用双摘要分页;展开的目录包含 `list_depth`,
`next_command` 保留深度、路径和两个摘要。更改深度会拒绝旧视图的续读,目录 cursor
Expand Down
15 changes: 11 additions & 4 deletions loopx/control_plane/quota/context_selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();
(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.
Expand Down
13 changes: 13 additions & 0 deletions tests/control_plane/test_context_capture_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
15 changes: 15 additions & 0 deletions tests/control_plane_ts/context_selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Loading