mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-16 08:16:04 -04:00
Compare commits
248 Commits
feat/134-1
...
41b769fc5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41b769fc5a | ||
|
|
7426ede2eb | ||
|
|
8f7eaffcef | ||
|
|
d2b5f5d498 | ||
|
|
607f071ca8 | ||
|
|
d3f8ff9916 | ||
|
|
204af77596 | ||
|
|
5c40d4e778 | ||
|
|
5625ba597b | ||
|
|
4f60cf70f1 | ||
|
|
6a37442ee1 | ||
|
|
0bca524c8c | ||
|
|
2ad56860df | ||
|
|
1fbde9f47f | ||
|
|
879962b826 | ||
|
|
0b0d55d7ec | ||
|
|
7214573f35 | ||
|
|
dcf11f8190 | ||
|
|
f79ca989ba | ||
|
|
e1641aa010 | ||
|
|
5cebdd999d | ||
|
|
bf533d77a7 | ||
|
|
e34209ff7f | ||
|
|
ff37d395bb | ||
|
|
f8d744bb37 | ||
|
|
c8c936ede1 | ||
|
|
57b3e3258b | ||
|
|
06e545325d | ||
|
|
ed3ccae844 | ||
|
|
f4e08d0ecf | ||
|
|
030f2ef20f | ||
|
|
16d6525de4 | ||
|
|
42c79218c9 | ||
|
|
4e0211d36c | ||
|
|
aec291caab | ||
|
|
43b182882a | ||
|
|
307b23d27f | ||
|
|
8c11dd16f4 | ||
|
|
2012718749 | ||
|
|
79d3b809f9 | ||
|
|
9ec4d8398e | ||
|
|
5f45740408 | ||
|
|
675d9ddc78 | ||
|
|
087e31d190 | ||
|
|
a6ee51baab | ||
|
|
6df60a4683 | ||
|
|
3cf0db8f79 | ||
|
|
964458ad4a | ||
|
|
d87c3e6400 | ||
|
|
ac888623a8 | ||
|
|
3a8ce83234 | ||
|
|
37b2b75287 | ||
|
|
f2dc615a8a | ||
|
|
9bc55f9946 | ||
|
|
180ebb3b02 | ||
|
|
534442b8da | ||
|
|
9c2ebb4f39 | ||
|
|
2c48400293 | ||
|
|
713ca7aee4 | ||
|
|
02b591ac64 | ||
|
|
f789525839 | ||
|
|
b1d8a66515 | ||
|
|
ad9e0234a9 | ||
|
|
145413d624 | ||
|
|
17da2964d7 | ||
|
|
9ab569e626 | ||
|
|
4af5664ff8 | ||
|
|
1864ce38ad | ||
|
|
74cc590407 | ||
|
|
a4b20ea34d | ||
|
|
8d0cee46d5 | ||
|
|
45b43b5a96 | ||
|
|
d15268e2cc | ||
|
|
424825f8cb | ||
|
|
07dad88e8c | ||
|
|
5c77896dec | ||
|
|
74bbf4b36f | ||
|
|
481585f865 | ||
|
|
c6e2a7dee4 | ||
|
|
83116555ff | ||
|
|
8f55870dad | ||
|
|
7244a82b36 | ||
|
|
5ab969e7ae | ||
|
|
5a4cc506d5 | ||
|
|
9e1eafd02d | ||
|
|
b2048856f3 | ||
|
|
19aaf9d05e | ||
|
|
8499599b70 | ||
|
|
86ff83c233 | ||
|
|
bd126905db | ||
|
|
f4a9674086 | ||
|
|
d3a982dda9 | ||
|
|
8cf628a53c | ||
|
|
b8f989b605 | ||
|
|
e29010ed48 | ||
|
|
0e5f695844 | ||
|
|
ce39d5c598 | ||
|
|
fad53e2df9 | ||
|
|
328fd114ff | ||
|
|
075c214439 | ||
|
|
ec882f4c88 | ||
|
|
7204844982 | ||
|
|
1fecdf096b | ||
|
|
3730b459a2 | ||
|
|
d7dbe951ce | ||
|
|
6c0c305a4b | ||
|
|
3c563fa1dc | ||
|
|
6aa4b85c95 | ||
|
|
b98b9a712e | ||
|
|
357629dbd9 | ||
|
|
12b65f9807 | ||
|
|
75c08bc982 | ||
|
|
553d25ee50 | ||
|
|
5be173edf6 | ||
|
|
28998422e2 | ||
|
|
b4733b67a6 | ||
|
|
ab44985916 | ||
|
|
d074d1c046 | ||
|
|
caeac828b5 | ||
|
|
85435ad4b5 | ||
|
|
5eb4b8a944 | ||
|
|
65aa559733 | ||
|
|
ac8a24b30b | ||
|
|
94b80a05d3 | ||
|
|
9b97c4d832 | ||
|
|
1206f4131d | ||
|
|
c99330372c | ||
|
|
9a512633a5 | ||
|
|
6ac13ffdad | ||
|
|
482681cdfe | ||
|
|
8e45f1850c | ||
|
|
57096b0a1a | ||
|
|
51b9e6b37f | ||
|
|
e939777f92 | ||
|
|
1093e26792 | ||
|
|
44cca2054d | ||
|
|
6dc7b26d82 | ||
|
|
a0bd406c8f | ||
|
|
b62646edfe | ||
|
|
d95b230cae | ||
|
|
f48f156754 | ||
|
|
52a909cebe | ||
|
|
c4c618e476 | ||
|
|
74338dc635 | ||
|
|
c092cf7fef | ||
|
|
8e24f3049e | ||
|
|
71d8e7b925 | ||
|
|
19947545e2 | ||
|
|
f7b2d8d6fe | ||
|
|
6f92e54dc0 | ||
|
|
31d9198a02 | ||
|
|
5eb1d7d824 | ||
|
|
3b03375e69 | ||
|
|
0f9e8915be | ||
|
|
ab95b75fcd | ||
|
|
ee44ff984d | ||
|
|
2ab26df4bd | ||
|
|
a2a38df9b8 | ||
|
|
fd90c9fe67 | ||
|
|
cca6f6829c | ||
|
|
c77d1a87e1 | ||
|
|
ee41b266d3 | ||
|
|
ca92c695f4 | ||
|
|
c6c01beaca | ||
|
|
970cdc925e | ||
|
|
b2f7a3354f | ||
|
|
2a08b7a35c | ||
|
|
a510f73422 | ||
|
|
1283c6d532 | ||
|
|
a1bfcd4110 | ||
|
|
c49839bb1f | ||
|
|
f65b2b4f0e | ||
|
|
f4b74e89dd | ||
|
|
5856913104 | ||
|
|
d45a0d2f5b | ||
|
|
dc47482e40 | ||
|
|
9537c97231 | ||
|
|
f56a5afcf7 | ||
|
|
3efaf551ed | ||
|
|
30c9b438ef | ||
|
|
587bb18572 | ||
|
|
24ccb59bd2 | ||
|
|
0e8e75ef75 | ||
|
|
0f7578c064 | ||
|
|
213d406cbf | ||
|
|
ee85fed6ca | ||
|
|
3a34d83749 | ||
|
|
981aff7c8b | ||
|
|
c94940effa | ||
|
|
b90875fa8e | ||
|
|
2567cbcc78 | ||
|
|
d607ff3674 | ||
|
|
cdf6282965 | ||
|
|
e7074f47ee | ||
|
|
9468383b67 | ||
|
|
1da2781816 | ||
|
|
9037430d52 | ||
|
|
8e22f757d8 | ||
|
|
7676b376ae | ||
|
|
1011a83823 | ||
|
|
1376d92064 | ||
|
|
be53e04671 | ||
|
|
cb56dc12ab | ||
|
|
71686a20fc | ||
|
|
07992b8a1b | ||
|
|
74ea754d29 | ||
|
|
77afde768c | ||
|
|
6db68a2baa | ||
|
|
5b910356a2 | ||
|
|
a389f8dff1 | ||
|
|
7a014170ba | ||
|
|
986f8e89fd | ||
|
|
ef1cfa1777 | ||
|
|
f1e4ad7574 | ||
|
|
14c5ef1808 | ||
|
|
9362900b1b | ||
|
|
ff45e971aa | ||
|
|
4b53b97e36 | ||
|
|
3cfe6e2b14 | ||
|
|
71f5f83adb | ||
|
|
79352a2d20 | ||
|
|
dddbd78dbd | ||
|
|
7bc66e86e8 | ||
|
|
eaa077bf91 | ||
|
|
bc259ec6f9 | ||
|
|
f84c7c4ed5 | ||
|
|
4cb8fa059a | ||
|
|
f877acacbf | ||
|
|
7d63699f9f | ||
|
|
faeaa1d30c | ||
|
|
e2a43fcd49 | ||
|
|
fcd5b49428 | ||
|
|
e73b6a2364 | ||
|
|
541c5bb95d | ||
|
|
611eed1537 | ||
|
|
7763ca3260 | ||
|
|
2665ada94e | ||
|
|
21b377d9c0 | ||
|
|
27ffd75f03 | ||
|
|
0cf8241978 | ||
|
|
36b3a09818 | ||
|
|
f3f6643fb9 | ||
|
|
883cef1a26 | ||
|
|
768c1abc78 | ||
|
|
a8beca1463 | ||
|
|
21adae9570 | ||
|
|
724a78604d | ||
|
|
91ba54d39f |
14886
.omx/cc2/board.json
Normal file
14886
.omx/cc2/board.json
Normal file
File diff suppressed because one or more lines are too long
842
.omx/cc2/board.md
Normal file
842
.omx/cc2/board.md
Normal file
File diff suppressed because one or more lines are too long
429
.omx/cc2/issue-parity-intake.json
Normal file
429
.omx/cc2/issue-parity-intake.json
Normal file
@@ -0,0 +1,429 @@
|
||||
{
|
||||
"schema_version": "cc2.issue_parity_intake.v1",
|
||||
"generated_at": "2026-05-14T08:02:00Z",
|
||||
"task_id": "3",
|
||||
"owner": "worker-2",
|
||||
"goal": "G001-stream0-board",
|
||||
"notes": [
|
||||
"Leader owns Ultragoal; this artifact does not mutate .omx/ultragoal.",
|
||||
"Rows are scoped intake/classification evidence for Worker 1/Task 2 board integration."
|
||||
],
|
||||
"source_manifest": {
|
||||
"claw_open_latest": {
|
||||
"path": ".omx/research/claw-open-latest.json",
|
||||
"sha256_prefix_from_plan": "89e3e027fa735f38",
|
||||
"covered_issue_numbers": [3028, 3029, 3030, 3031, 3032, 3033, 3034, 3035, 3036, 3037, 3038]
|
||||
},
|
||||
"claw_issues": {
|
||||
"path": ".omx/research/claw-issues.json",
|
||||
"sha256_prefix_from_plan": "e64fdba7df3b78ed",
|
||||
"covered_issue_numbers": [2997, 3003, 3004, 3005, 3006, 3007, 3020, 3023]
|
||||
},
|
||||
"opencode": {
|
||||
"repo_path": ".omx/research/repos/opencode",
|
||||
"metadata_path": ".omx/research/opencode-repo.json",
|
||||
"issues_path": ".omx/research/opencode-issues.json",
|
||||
"head_from_plan": "27ac53aaacc677b1401c4e75ca7a7dadf8b2c349"
|
||||
},
|
||||
"codex": {
|
||||
"repo_path": ".omx/research/repos/codex",
|
||||
"metadata_path": ".omx/research/codex-repo.json",
|
||||
"issues_path": ".omx/research/codex-issues.json",
|
||||
"head_from_plan": "6a225e4005209f2325ab3c681c7c6beba2907d4d"
|
||||
}
|
||||
},
|
||||
"issue_clusters": [
|
||||
{
|
||||
"id": "CC2-ISSUE-3007",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3007",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3007,
|
||||
"title": "Permission modes do not enforce path scope on file tools or shell expansion in bash",
|
||||
"theme": "security/path-scope",
|
||||
"release_bucket": "alpha_blocker",
|
||||
"lifecycle_status": "active",
|
||||
"roadmap_anchor": "ROADMAP.md#11-policy-engine-for-autonomous-coding; ROADMAP.md#9-green-ness-contract",
|
||||
"dependencies": ["permission path canonicalization", "file tool target validation", "bash command/path validation reachability", "policy regression fixtures"],
|
||||
"verification_required": ["workspace-write cannot read/write/delete outside workspace", "shell expansion and symlink traversal are rejected or policy-blocked", "file tools and bash use the same target-scope decision record"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Security/sandbox escape class; plan names #3007 as alpha blocker."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3020",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3020",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3020,
|
||||
"title": "OpenAI-compatible model IDs with slashes are stripped before request",
|
||||
"theme": "provider/model-routing",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#provider-routing-model-name-prefix-must-win-over-env-var-presence-fixed-2026-04-08-0530c50",
|
||||
"dependencies": ["provider profile contract", "wire model-id preservation option", "routing-prefix source reporting"],
|
||||
"verification_required": ["OpenAI-compatible endpoint receives exact model id when preservation is enabled", "status JSON reports raw model input, route, and wire model id"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Core provider correctness but below alpha state/security contracts unless it blocks the selected alpha model path."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3006",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3006",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3006,
|
||||
"title": "Not Working in windows",
|
||||
"theme": "windows/install",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#immediate-backlog-from-current-real-pain",
|
||||
"dependencies": ["Windows support policy", "PowerShell install path", "dependency/version matrix", "diagnostic setup output"],
|
||||
"verification_required": ["fresh Windows/PowerShell setup smoke documented", "unsupported native paths fail with actionable WSL2/native guidance"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Real adoption blocker; plan places Windows/install in beta adoption overlay."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3005",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3005",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3005,
|
||||
"title": "DeepSeek V4-flash/pro fails with 400 Bad Request (missing reasoning_content) while deepseek-reasoner works",
|
||||
"theme": "provider/response-shape",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#5-failure-taxonomy; ROADMAP.md#provider-routing-model-name-prefix-must-win-over-env-var-presence-fixed-2026-04-08-0530c50",
|
||||
"dependencies": ["OpenAI-compatible diagnostics playbook", "provider error taxonomy", "reasoning/thinking field compatibility tests"],
|
||||
"verification_required": ["provider 400 response classified with actionable remediation", "DeepSeek-compatible response-shape fixture does not hide assistant output"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Provider compatibility issue that shares the #3032 diagnostics lane."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3004",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3004",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3004,
|
||||
"title": "When can we adapt to zed?",
|
||||
"theme": "ide/acp",
|
||||
"release_bucket": "ga_ecosystem",
|
||||
"lifecycle_status": "deferred_with_rationale",
|
||||
"roadmap_anchor": "ROADMAP.md#phase-5-plugin-and-mcp-lifecycle-maturity",
|
||||
"dependencies": ["stable session/control API", "plugin/MCP lifecycle", "engine API or ACP bridge decision"],
|
||||
"verification_required": ["Zed/ACP smoke once core state/control contracts exist"],
|
||||
"deferral_rationale": "IDE integration is valuable but should wait until boot/session/event/control truth surfaces are stable.",
|
||||
"classification_rationale": "Matches plan's GA ecosystem lane for Zed/ACP."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3003",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3003",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3003,
|
||||
"title": ".claude/sessions should not be submitted to repo",
|
||||
"theme": "session-hygiene/gitignore",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#9-green-ness-contract; ROADMAP.md#8-recovery-recipes-for-common-failures",
|
||||
"dependencies": ["artifact ignore policy", "session storage boundary docs", "repo hygiene check"],
|
||||
"verification_required": ["session directories are ignored", "status/doctor warns about tracked session artifacts"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Small but user-visible session hygiene and data-leak prevention item."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-2997",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/2997",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 2997,
|
||||
"title": "License?",
|
||||
"theme": "docs/license",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#immediate-backlog-from-current-real-pain",
|
||||
"dependencies": ["maintainer license decision", "LICENSE file", "README/USAGE attribution wording"],
|
||||
"verification_required": ["repository license file exists", "package metadata and docs reference the same license"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Adoption/readiness documentation gap; requires maintainer decision before implementation."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3023",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3023",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3023,
|
||||
"title": "Protect claw-code from AI slop PRs",
|
||||
"theme": "repo-hygiene/anti-slop",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#immediate-backlog-from-current-real-pain",
|
||||
"dependencies": ["contributor policy", "PR quality gate selection", "false-positive review escape hatch"],
|
||||
"verification_required": ["selected PR quality gate runs on sample good/bad PR fixtures", "maintainers can override false positives"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Protects project throughput but should not precede alpha core safety contracts."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3028",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3028",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3028,
|
||||
"title": "docs: add navigation and file-context usage guide",
|
||||
"theme": "docs/navigation-context",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#7-human-ux-still-leaks-into-claw-workflows",
|
||||
"dependencies": ["current TUI/shell key behavior inventory", "file context syntax docs", "secret-handling guidance"],
|
||||
"verification_required": ["docs include terminal history, scrollback, @file context, attach/external file caveats", "examples work against current CLI"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Documentation support item from latest open issue refresh."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3029",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3029",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3029,
|
||||
"title": "build: add cross-platform installer path and release artifact quickstart",
|
||||
"theme": "install/distribution",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#immediate-backlog-from-current-real-pain",
|
||||
"dependencies": ["release artifact policy", "install.sh/install.ps1 contract", "PATH/update/uninstall instructions"],
|
||||
"verification_required": ["install quickstart smoke on supported OS/arch", "failed install prints actionable diagnostics"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Distribution friction belongs in adoption overlay."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3030",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3030",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3030,
|
||||
"title": "feat: make provider/model setup less env-var-driven",
|
||||
"theme": "provider/setup-profiles",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#3-structured-session-control-api; ROADMAP.md#145-boot-preflight-doctor-contract",
|
||||
"dependencies": ["provider profiles", "setup wizard or dry-run", "secret redaction", "base-url/model smoke test"],
|
||||
"verification_required": ["setup validates provider route without echoing keys", "session-only versus persisted profile behavior is explicit"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Directly reduces current provider setup support churn."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3031",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3031",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3031,
|
||||
"title": "feat: auto-compact or clearly recover from context-window provider errors",
|
||||
"theme": "session-recovery/context-window",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#8-recovery-recipes-for-common-failures; ROADMAP.md#158-compact_messages_if_needed-drops-turns-silently-no-structured-compaction-event-emitted",
|
||||
"dependencies": ["provider error classifier", "safe compact retry policy", "compaction event/audit trail", "retry loop cap"],
|
||||
"verification_required": ["context-window error either compacts+retries once safely or emits exact recovery command", "compaction event is machine-visible"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Recovery reliability item; promoted only if selected alpha provider path hits it."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3032",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3032",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3032,
|
||||
"title": "docs: add OpenAI-compatible/local provider diagnostics playbook",
|
||||
"theme": "provider/diagnostics-docs",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#5-failure-taxonomy",
|
||||
"dependencies": ["raw chat-completions smoke tests", "tool-call response-shape examples", "provider failure taxonomy"],
|
||||
"verification_required": ["playbook distinguishes Claw bugs from wrapper/tool-call-shape bugs", "curl examples cover non-streaming and streaming tool calls"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Shared diagnostic lane for #3005/#3020/local model reports."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3033",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3033",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3033,
|
||||
"title": "feat: add minimal claw serve JSON-RPC engine API",
|
||||
"theme": "engine-api/control-plane",
|
||||
"release_bucket": "ga_ecosystem",
|
||||
"lifecycle_status": "deferred_with_rationale",
|
||||
"roadmap_anchor": "ROADMAP.md#3-structured-session-control-api; ROADMAP.md#phase-4-claws-first-task-execution",
|
||||
"dependencies": ["stable session state API", "event schema v1", "permission policy contract", "cancel/prompt stream semantics"],
|
||||
"verification_required": ["protocol conformance fixtures for session/create prompt/stream cancel error", "capability negotiation backwards compatibility"],
|
||||
"deferral_rationale": "Engine API should expose, not invent, stable core control-plane semantics after alpha contracts land.",
|
||||
"classification_rationale": "Useful integration surface but too broad for alpha unless narrowed to existing session control API."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3034",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3034",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3034,
|
||||
"title": "docs: define evidence-gated Hermes handoff loop for Claw Code execution",
|
||||
"theme": "sdlc/evidence-handoff",
|
||||
"release_bucket": "post_2_0_research",
|
||||
"lifecycle_status": "deferred_with_rationale",
|
||||
"roadmap_anchor": "ROADMAP.md#4-canonical-lane-event-schema; ROADMAP.md#10-typed-task-packet-format",
|
||||
"dependencies": ["typed task packet", "evidence bundle schema", "report gate status vocabulary"],
|
||||
"verification_required": ["handoff packet fixture validates scope/success/test evidence fields", "post-flight gate consumes evidence instead of free-text summary"],
|
||||
"deferral_rationale": "Can inform event/report/task contracts, but Hermes-specific loop should stay research/docs until core schemas are stable.",
|
||||
"classification_rationale": "Only the generic evidence-gated contract is Claw 2.0; Hermes branding is not core."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3035",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3035",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3035,
|
||||
"title": "fix: improve compacted session resume discoverability",
|
||||
"theme": "session-resume/discoverability",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#8-recovery-recipes-for-common-failures; ROADMAP.md#160-session_store-has-no-list_sessions-delete_session-or-session_exists",
|
||||
"dependencies": ["session enumeration", "latest-session workspace search boundary", "compacted session marker"],
|
||||
"verification_required": ["/resume latest finds newest eligible compacted session", "/session or status lists resumable compacted sessions with path/id"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Session recovery/adoption item."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3036",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3036",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3036,
|
||||
"title": "docs: add official Ollama/llama.cpp/vLLM local model examples",
|
||||
"theme": "provider/local-docs",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#145-boot-preflight-doctor-contract; ROADMAP.md#5-failure-taxonomy",
|
||||
"dependencies": ["known-good local provider examples", "raw /v1 smoke test", "tool-call limitation warning"],
|
||||
"verification_required": ["docs include Ollama/llama.cpp/vLLM examples and HELLO smoke", "tool-call caveats are explicit"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Local provider adoption support."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3037",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3037",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3037,
|
||||
"title": "docs: clarify Claw Code positioning as multi-provider Claude-Code-shaped runtime",
|
||||
"theme": "docs/product-positioning",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"roadmap_anchor": "ROADMAP.md#goal; ROADMAP.md#definition-of-clawable",
|
||||
"dependencies": ["README positioning copy", "provider support truth table", "identity leak bug policy"],
|
||||
"verification_required": ["README/docs answer Claude-only question directly", "provider support wording matches implemented routes"],
|
||||
"deferral_rationale": null,
|
||||
"classification_rationale": "Clarifies product identity for adoption without broad implementation."
|
||||
},
|
||||
{
|
||||
"id": "CC2-ISSUE-3038",
|
||||
"source_anchor": "https://github.com/ultraworkers/claw-code/issues/3038",
|
||||
"source_type": "github_issue",
|
||||
"source_number": 3038,
|
||||
"title": "roadmap: track skills/plugins/marketplace ecosystem gap after core UX stabilizes",
|
||||
"theme": "plugin-marketplace/ecosystem",
|
||||
"release_bucket": "ga_ecosystem",
|
||||
"lifecycle_status": "deferred_with_rationale",
|
||||
"roadmap_anchor": "ROADMAP.md#13-first-class-pluginmcp-lifecycle-contract; ROADMAP.md#14-mcp-end-to-end-lifecycle-parity",
|
||||
"dependencies": ["plugin/MCP lifecycle contract", "extension point inventory", "discovery/install/update flow design"],
|
||||
"verification_required": ["extension point inventory exists", "marketplace work explicitly depends on core UX stabilization"],
|
||||
"deferral_rationale": "Marketplace breadth should wait until core setup/auth/provider/session UX and plugin lifecycle are reliable.",
|
||||
"classification_rationale": "Matches plan's ga_ecosystem/post-2.0 caution for marketplace parity."
|
||||
}
|
||||
],
|
||||
"parity_rows": [
|
||||
{
|
||||
"id": "CC2-PARITY-OPENCODE-PLUGIN-ECOSYSTEM",
|
||||
"source_anchor": "anomalyco/opencode@27ac53aa packages/app/web/desktop/plugin/sdk/extensions/zed/slack/containers plus issue #3038",
|
||||
"source_type": "repo_clone_and_local_issue",
|
||||
"title": "Plugin/skills/marketplace ecosystem inventory",
|
||||
"release_bucket": "ga_ecosystem",
|
||||
"lifecycle_status": "deferred_with_rationale",
|
||||
"dependencies": ["Claw plugin/MCP lifecycle contract", "current extension-point inventory"],
|
||||
"verification_required": ["inventory maps current Claw plugin/skill/MCP extension points before marketplace implementation"],
|
||||
"deferral_rationale": "Adapt ecosystem discovery only after core setup/provider/session reliability is stable."
|
||||
},
|
||||
{
|
||||
"id": "CC2-PARITY-OPENCODE-PERMISSION-PRESETS",
|
||||
"source_anchor": "https://github.com/anomalyco/opencode/issues/27464 and ROADMAP.md#11-policy-engine-for-autonomous-coding",
|
||||
"source_type": "external_issue_and_roadmap",
|
||||
"title": "Quick permission preset switching mapped onto Claw policy profiles",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"dependencies": ["policy profile model", "approval-token audit trail"],
|
||||
"verification_required": ["preset switch is visible in status/report output and cannot bypass path-scope enforcement"],
|
||||
"deferral_rationale": null
|
||||
},
|
||||
{
|
||||
"id": "CC2-PARITY-OPENCODE-CUSTOM-PROVIDER-PARAMS",
|
||||
"source_anchor": "https://github.com/anomalyco/opencode/issues/27462 and #3030/#3032",
|
||||
"source_type": "external_issue_and_local_issue",
|
||||
"title": "Custom API parameter passthrough for provider profiles",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"dependencies": ["provider profile schema", "secret redaction", "request audit surface"],
|
||||
"verification_required": ["custom params are schema-validated, redacted, and visible as provenance without leaking secrets"],
|
||||
"deferral_rationale": null
|
||||
},
|
||||
{
|
||||
"id": "CC2-PARITY-OPENCODE-TODOWRITE-AUTOCOMPLETE",
|
||||
"source_anchor": "https://github.com/anomalyco/opencode/issues/27453 and ROADMAP.md#10-typed-task-packet-format",
|
||||
"source_type": "external_issue_and_roadmap",
|
||||
"title": "Task/Todo completion assistance via typed task lifecycle",
|
||||
"release_bucket": "ga_ecosystem",
|
||||
"lifecycle_status": "deferred_with_rationale",
|
||||
"dependencies": ["typed task packet", "task lifecycle events", "evidence-gated completion"],
|
||||
"verification_required": ["auto-complete suggestions cannot mark work complete without evidence bundle or explicit user approval"],
|
||||
"deferral_rationale": "Useful UX should follow, not precede, typed task lifecycle and evidence contract."
|
||||
},
|
||||
{
|
||||
"id": "CC2-PARITY-OPENCODE-WINDOWS-DISTRIBUTION",
|
||||
"source_anchor": "https://github.com/anomalyco/opencode/issues/27476 https://github.com/anomalyco/opencode/issues/27459 https://github.com/anomalyco/opencode/issues/27470 and #3006/#3029",
|
||||
"source_type": "external_issues_and_local_issues",
|
||||
"title": "Windows/GLIBC/distribution reliability parity lessons",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"dependencies": ["install artifact matrix", "Windows encoding guidance", "minimum Linux/GLIBC support statement"],
|
||||
"verification_required": ["release quickstart documents supported OS matrix and known terminal/encoding caveats"],
|
||||
"deferral_rationale": null
|
||||
},
|
||||
{
|
||||
"id": "CC2-PARITY-CODEX-GRANULAR-PERMISSIONS",
|
||||
"source_anchor": "https://github.com/openai/codex/issues/22595 and Codex docs permissions/app/plugin concepts",
|
||||
"source_type": "external_issue_and_docs",
|
||||
"title": "Granular app/plugin permissions adapted to Claw policy engine",
|
||||
"release_bucket": "alpha_blocker",
|
||||
"lifecycle_status": "active",
|
||||
"dependencies": ["permission enforcer path-scope fix", "plugin/MCP capability model", "approval-token replay protection"],
|
||||
"verification_required": ["granular permission grants do not widen workspace path scope implicitly"],
|
||||
"deferral_rationale": null
|
||||
},
|
||||
{
|
||||
"id": "CC2-PARITY-CODEX-SESSION-RECOVERY",
|
||||
"source_anchor": "https://github.com/openai/codex/issues/22619 https://github.com/openai/codex/issues/22597 https://github.com/openai/codex/issues/22593 and #3035",
|
||||
"source_type": "external_issues_and_local_issue",
|
||||
"title": "Safe local session/thread recovery without storage amplification",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"dependencies": ["session enumeration", "resume latest boundary", "JSONL/storage compaction policy"],
|
||||
"verification_required": ["recoverable sessions are discoverable and session forks avoid unbounded duplicate history"],
|
||||
"deferral_rationale": null
|
||||
},
|
||||
{
|
||||
"id": "CC2-PARITY-CODEX-PROXY-NETWORK",
|
||||
"source_anchor": "https://github.com/openai/codex/issues/22623 and #3032",
|
||||
"source_type": "external_issue_and_local_issue",
|
||||
"title": "Provider/network diagnostics include proxy behavior",
|
||||
"release_bucket": "beta_adoption",
|
||||
"lifecycle_status": "open",
|
||||
"dependencies": ["HTTP client proxy detection", "provider diagnostics playbook"],
|
||||
"verification_required": ["diagnostics report whether proxy env/config is honored for provider calls"],
|
||||
"deferral_rationale": null
|
||||
},
|
||||
{
|
||||
"id": "CC2-PARITY-CODEX-CLI-AGENT-FLAG",
|
||||
"source_anchor": "https://github.com/openai/codex/issues/22615 and ROADMAP.md#10-typed-task-packet-format",
|
||||
"source_type": "external_issue_and_roadmap",
|
||||
"title": "CLI flag for agent/subagent mode mapped to Claw typed task packets",
|
||||
"release_bucket": "ga_ecosystem",
|
||||
"lifecycle_status": "deferred_with_rationale",
|
||||
"dependencies": ["typed task packet", "session control API", "policy-scoped worker launch"],
|
||||
"verification_required": ["CLI agent mode cannot bypass task policy or evidence requirements"],
|
||||
"deferral_rationale": "Implement only after core task/session control contracts are stable."
|
||||
}
|
||||
],
|
||||
"coverage": {
|
||||
"required_latest_open_range_3028_3038": [3028, 3029, 3030, 3031, 3032, 3033, 3034, 3035, 3036, 3037, 3038],
|
||||
"required_existing_issue_numbers": [3007, 3006, 3020, 3005, 3003, 2997, 3023, 3004],
|
||||
"issue_rows_expected": 19,
|
||||
"parity_rows_expected_minimum": 6
|
||||
}
|
||||
}
|
||||
47
.omx/cc2/issue-parity-intake.md
Normal file
47
.omx/cc2/issue-parity-intake.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# CC2 Issue / Parity Intake Mapping
|
||||
|
||||
Generated by `worker-2` for team task 3 (`G001 issue/parity intake mapping`). This is a board-integration fragment for Stream 0; it intentionally does **not** mutate `.omx/ultragoal`.
|
||||
|
||||
## Covered local issue clusters
|
||||
|
||||
| Issue | Theme | Bucket | Lifecycle | Board anchor |
|
||||
|---:|---|---|---|---|
|
||||
| #3007 | security/path-scope | `alpha_blocker` | `active` | Policy engine + green-ness contract |
|
||||
| #3020 | provider/model-routing | `beta_adoption` | `open` | Provider routing/model source status |
|
||||
| #3006 | windows/install | `beta_adoption` | `open` | Immediate backlog / install readiness |
|
||||
| #3005 | provider/response-shape | `beta_adoption` | `open` | Failure taxonomy / provider diagnostics |
|
||||
| #3004 | ide/acp | `ga_ecosystem` | `deferred_with_rationale` | Plugin/MCP lifecycle maturity |
|
||||
| #3003 | session-hygiene/gitignore | `beta_adoption` | `open` | Green-ness / recovery hygiene |
|
||||
| #2997 | docs/license | `beta_adoption` | `open` | Adoption docs/license readiness |
|
||||
| #3023 | repo-hygiene/anti-slop | `beta_adoption` | `open` | Immediate backlog / PR quality gate |
|
||||
| #3028 | docs/navigation-context | `beta_adoption` | `open` | Human UX leaks into claw workflows |
|
||||
| #3029 | install/distribution | `beta_adoption` | `open` | Cross-platform release quickstart |
|
||||
| #3030 | provider/setup-profiles | `beta_adoption` | `open` | Boot preflight / structured session control |
|
||||
| #3031 | session-recovery/context-window | `beta_adoption` | `open` | Recovery recipes / compaction event |
|
||||
| #3032 | provider/diagnostics-docs | `beta_adoption` | `open` | Failure taxonomy |
|
||||
| #3033 | engine-api/control-plane | `ga_ecosystem` | `deferred_with_rationale` | Structured session control API |
|
||||
| #3034 | sdlc/evidence-handoff | `post_2_0_research` | `deferred_with_rationale` | Event/report/task contract input |
|
||||
| #3035 | session-resume/discoverability | `beta_adoption` | `open` | Recovery recipes / session enumeration |
|
||||
| #3036 | provider/local-docs | `beta_adoption` | `open` | Provider setup and diagnostics docs |
|
||||
| #3037 | docs/product-positioning | `beta_adoption` | `open` | Goal / definition of clawable |
|
||||
| #3038 | plugin-marketplace/ecosystem | `ga_ecosystem` | `deferred_with_rationale` | Plugin/MCP lifecycle maturity |
|
||||
|
||||
## Parity intake rows
|
||||
|
||||
| Row | Source | Bucket | Lifecycle | Adaptation rule |
|
||||
|---|---|---|---|---|
|
||||
| `CC2-PARITY-OPENCODE-PLUGIN-ECOSYSTEM` | opencode repo + #3038 | `ga_ecosystem` | `deferred_with_rationale` | Inventory Claw extension points before marketplace work. |
|
||||
| `CC2-PARITY-OPENCODE-PERMISSION-PRESETS` | opencode #27464 | `beta_adoption` | `open` | Permission preset UX must not bypass Claw path-scope policy. |
|
||||
| `CC2-PARITY-OPENCODE-CUSTOM-PROVIDER-PARAMS` | opencode #27462 + #3030/#3032 | `beta_adoption` | `open` | Custom provider params need schema validation, redaction, and provenance. |
|
||||
| `CC2-PARITY-OPENCODE-TODOWRITE-AUTOCOMPLETE` | opencode #27453 | `ga_ecosystem` | `deferred_with_rationale` | Auto-complete task UX follows typed task lifecycle/evidence gates. |
|
||||
| `CC2-PARITY-OPENCODE-WINDOWS-DISTRIBUTION` | opencode #27476/#27459/#27470 + #3006/#3029 | `beta_adoption` | `open` | Use external pain as release-matrix and diagnostics evidence. |
|
||||
| `CC2-PARITY-CODEX-GRANULAR-PERMISSIONS` | Codex #22595 + docs | `alpha_blocker` | `active` | Adapt granular permissions only through Claw policy engine and approval tokens. |
|
||||
| `CC2-PARITY-CODEX-SESSION-RECOVERY` | Codex #22619/#22597/#22593 + #3035 | `beta_adoption` | `open` | Session discovery/recovery must avoid storage amplification. |
|
||||
| `CC2-PARITY-CODEX-PROXY-NETWORK` | Codex #22623 + #3032 | `beta_adoption` | `open` | Provider diagnostics should expose proxy behavior. |
|
||||
| `CC2-PARITY-CODEX-CLI-AGENT-FLAG` | Codex #22615 | `ga_ecosystem` | `deferred_with_rationale` | CLI agent mode waits for typed task/session control contracts. |
|
||||
|
||||
Validation command:
|
||||
|
||||
```bash
|
||||
python3 .omx/cc2/validate_issue_parity_intake.py
|
||||
```
|
||||
250
.omx/cc2/render_board_md.py
Executable file
250
.omx/cc2/render_board_md.py
Executable file
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the Claw Code 2.0 canonical board JSON as a human-readable Markdown board."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
STATUS_DESCRIPTIONS = {
|
||||
"context": "Context-only heading or evidence anchor; not an implementation work item.",
|
||||
"active": "Current Claw Code 2.0 implementation surface that should remain visible on the board.",
|
||||
"open": "Actionable unresolved work that needs implementation or acceptance evidence.",
|
||||
"done_verify": "Marked as done upstream but retained for verification against current CC2 behavior.",
|
||||
"stale_done": "Historically completed or merged work that may be stale and needs freshness checks before relying on it.",
|
||||
"superseded": "Replaced by a newer item; keep as traceability context only.",
|
||||
"deferred_with_rationale": "Intentionally deferred; rationale must be present in the board item.",
|
||||
"rejected_not_claw": "Excluded because it is not Claw Code product work.",
|
||||
}
|
||||
|
||||
BUCKET_DESCRIPTIONS = {
|
||||
"alpha_blocker": "Must be resolved before alpha-quality autonomous coding lanes are dependable.",
|
||||
"beta_adoption": "Important for broader dogfood/adoption once alpha blockers are controlled.",
|
||||
"ga_ecosystem": "Required for mature plugin/MCP/provider ecosystem behavior.",
|
||||
"2.x_intake": "Post-2.0 intake or follow-up candidate retained for sequencing.",
|
||||
"post_2_0_research": "Research-oriented item not required for the CC2 board cut.",
|
||||
"context": "Non-actionable roadmap context.",
|
||||
"rejected_not_claw": "Explicit non-Claw rejection bucket.",
|
||||
}
|
||||
|
||||
LANE_TITLES = {
|
||||
"stream_0_governance": "Stream 0 — Governance, intake, and cross-cutting roadmap triage",
|
||||
"stream_1_worker_boot_session_control": "Stream 1 — Worker boot and session control",
|
||||
"stream_2_event_reporting_contracts": "Stream 2 — Event/reporting contracts",
|
||||
"stream_3_branch_test_recovery": "Stream 3 — Branch/test recovery",
|
||||
"stream_4_claws_first_execution": "Stream 4 — Claws-first task execution",
|
||||
"stream_5_plugin_mcp_lifecycle": "Stream 5 — Plugin/MCP lifecycle",
|
||||
"adoption_overlay": "Adoption overlay — user-visible parity and release polish",
|
||||
"parity_overlay": "Parity overlay — opencode/codex comparison context",
|
||||
}
|
||||
|
||||
REQUIRED_ITEM_FIELDS = [
|
||||
"id",
|
||||
"title",
|
||||
"source_anchor",
|
||||
"source_type",
|
||||
"release_bucket",
|
||||
"lifecycle_status",
|
||||
"dependencies",
|
||||
"verification_required",
|
||||
"deferral_rationale",
|
||||
]
|
||||
|
||||
|
||||
def load_board(path: Path) -> dict[str, Any]:
|
||||
with path.open() as f:
|
||||
board = json.load(f)
|
||||
if not isinstance(board, dict):
|
||||
raise ValueError("board JSON root must be an object")
|
||||
items = board.get("items")
|
||||
if not isinstance(items, list):
|
||||
raise ValueError("board JSON must contain an items array")
|
||||
return board
|
||||
|
||||
|
||||
def validate_board(board: dict[str, Any]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
coverage = board.get("coverage", {})
|
||||
if coverage.get("unmapped_roadmap_heading_lines"):
|
||||
errors.append(f"unmapped roadmap heading lines: {coverage['unmapped_roadmap_heading_lines']}")
|
||||
if coverage.get("roadmap_headings_mapped") != coverage.get("roadmap_headings_total"):
|
||||
errors.append("roadmap heading coverage is incomplete")
|
||||
if coverage.get("roadmap_actions_mapped") != coverage.get("roadmap_actions_total"):
|
||||
errors.append("roadmap ordered-action coverage is incomplete")
|
||||
|
||||
allowed_status = set(board.get("generation_policy", {}).get("status_values", []))
|
||||
allowed_buckets = set(board.get("generation_policy", {}).get("release_buckets", []))
|
||||
seen_ids: set[str] = set()
|
||||
for index, item in enumerate(board["items"], 1):
|
||||
for field in REQUIRED_ITEM_FIELDS:
|
||||
if field not in item:
|
||||
errors.append(f"item {index} missing required field {field}")
|
||||
item_id = item.get("id")
|
||||
if item_id in seen_ids:
|
||||
errors.append(f"duplicate item id {item_id}")
|
||||
seen_ids.add(item_id)
|
||||
status = item.get("lifecycle_status")
|
||||
bucket = item.get("release_bucket")
|
||||
if allowed_status and status not in allowed_status:
|
||||
errors.append(f"{item_id} has unknown lifecycle_status {status!r}")
|
||||
if allowed_buckets and bucket not in allowed_buckets:
|
||||
errors.append(f"{item_id} has unknown release_bucket {bucket!r}")
|
||||
if status == "deferred_with_rationale" and not str(item.get("deferral_rationale", "")).strip():
|
||||
errors.append(f"{item_id} is deferred without deferral_rationale")
|
||||
return errors
|
||||
|
||||
|
||||
def table(headers: list[str], rows: list[list[Any]]) -> list[str]:
|
||||
out = ["| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |"]
|
||||
for row in rows:
|
||||
out.append("| " + " | ".join(str(cell) for cell in row) + " |")
|
||||
return out
|
||||
|
||||
|
||||
def fmt_list(value: Any) -> str:
|
||||
if not value:
|
||||
return "none"
|
||||
if isinstance(value, list):
|
||||
return ", ".join(f"`{v}`" for v in value) if value else "none"
|
||||
return f"`{value}`"
|
||||
|
||||
|
||||
def render(board: dict[str, Any]) -> str:
|
||||
items: list[dict[str, Any]] = board["items"]
|
||||
summary = board.get("summary", {})
|
||||
coverage = board.get("coverage", {})
|
||||
sources = board.get("sources", {})
|
||||
policy = board.get("generation_policy", {})
|
||||
by_lane = Counter(item.get("owner_lane", "unassigned") for item in items)
|
||||
by_status = Counter(item.get("lifecycle_status", "unknown") for item in items)
|
||||
by_bucket = Counter(item.get("release_bucket", "unknown") for item in items)
|
||||
by_source = Counter(item.get("source_type", "unknown") for item in items)
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("# Claw Code 2.0 Canonical Board")
|
||||
lines.append("")
|
||||
lines.append(f"Generated from board schema: `{board.get('generated_at', 'unknown')}`")
|
||||
lines.append(f"Schema version: `{board.get('schema_version', 'unknown')}`")
|
||||
lines.append("Ultragoal mutation policy: `.omx/ultragoal` is leader-owned and was not modified by this rendering task.")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Evidence Freeze")
|
||||
lines.append("")
|
||||
roadmap = sources.get("roadmap", {})
|
||||
research = sources.get("research", {})
|
||||
plan = sources.get("approved_plan", {})
|
||||
lines.extend(table(["Source", "Frozen evidence"], [
|
||||
["Roadmap", f"`{roadmap.get('path', 'ROADMAP.md')}` sha256 prefix `{roadmap.get('sha256_prefix', 'unknown')}`; {roadmap.get('heading_count', '?')} headings; {roadmap.get('ordered_action_count', '?')} ordered actions"],
|
||||
["Approved plan", f"`{plan.get('path', '.omx/plans/claw-code-2-0-adaptive-plan.md')}` sha256 prefix `{plan.get('sha256_prefix', 'unknown')}`"],
|
||||
["Research bundle", f"root `{research.get('root', '.omx/research')}`; latest open issues {research.get('claw_open_latest_count', '?')}; issue corpus {research.get('claw_issues_count', '?')}; codex/opencode clone metadata included"],
|
||||
]))
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Roadmap Coverage Summary")
|
||||
lines.append("")
|
||||
heading_total = coverage.get("roadmap_headings_total", 0)
|
||||
heading_mapped = coverage.get("roadmap_headings_mapped", 0)
|
||||
action_total = coverage.get("roadmap_actions_total", 0)
|
||||
action_mapped = coverage.get("roadmap_actions_mapped", 0)
|
||||
lines.extend(table(["Coverage gate", "Mapped", "Total", "Status"], [
|
||||
["ROADMAP headings", heading_mapped, heading_total, "PASS" if heading_mapped == heading_total and not coverage.get("unmapped_roadmap_heading_lines") else "FAIL"],
|
||||
["ROADMAP ordered actions", action_mapped, action_total, "PASS" if action_mapped == action_total else "FAIL"],
|
||||
["Duplicate heading lines", len(coverage.get("duplicate_roadmap_heading_lines", [])), 0, "PASS" if not coverage.get("duplicate_roadmap_heading_lines") else "WARN"],
|
||||
]))
|
||||
lines.append("")
|
||||
lines.append(f"Total canonical board items: **{len(items)}**")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Lifecycle Enum Reference")
|
||||
lines.append("")
|
||||
status_rows = []
|
||||
for status in policy.get("status_values", sorted(by_status)):
|
||||
status_rows.append([f"`{status}`", by_status.get(status, 0), STATUS_DESCRIPTIONS.get(status, "Board-defined lifecycle status.")])
|
||||
lines.extend(table(["Lifecycle", "Count", "Meaning"], status_rows))
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Release Bucket Reference")
|
||||
lines.append("")
|
||||
bucket_rows = []
|
||||
for bucket in policy.get("release_buckets", sorted(by_bucket)):
|
||||
bucket_rows.append([f"`{bucket}`", by_bucket.get(bucket, 0), BUCKET_DESCRIPTIONS.get(bucket, "Board-defined release bucket.")])
|
||||
lines.extend(table(["Bucket", "Count", "Meaning"], bucket_rows))
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Stream Summaries")
|
||||
lines.append("")
|
||||
lane_rows = []
|
||||
for lane, count in sorted(by_lane.items()):
|
||||
lane_items = [item for item in items if item.get("owner_lane") == lane]
|
||||
lane_status = Counter(item.get("lifecycle_status") for item in lane_items)
|
||||
open_like = lane_status.get("active", 0) + lane_status.get("open", 0) + lane_status.get("done_verify", 0)
|
||||
lane_rows.append([
|
||||
LANE_TITLES.get(lane, lane),
|
||||
count,
|
||||
open_like,
|
||||
", ".join(f"`{k}` {v}" for k, v in sorted(lane_status.items())),
|
||||
])
|
||||
lines.extend(table(["Stream / lane", "Items", "Active+open+verify", "Lifecycle mix"], lane_rows))
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Source-Type Mix")
|
||||
lines.append("")
|
||||
lines.extend(table(["Source type", "Items"], [[f"`{k}`", v] for k, v in sorted(by_source.items())]))
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Board Items by Stream")
|
||||
lines.append("")
|
||||
for lane in sorted(by_lane):
|
||||
lane_items = [item for item in items if item.get("owner_lane") == lane]
|
||||
lines.append(f"### {LANE_TITLES.get(lane, lane)}")
|
||||
lines.append("")
|
||||
lines.extend(table(
|
||||
["ID", "Title", "Source", "Bucket", "Lifecycle", "Verification", "Dependencies", "Deferral"],
|
||||
[[
|
||||
f"`{item.get('id')}`",
|
||||
str(item.get("title", "")).replace("|", "\\|"),
|
||||
f"`{item.get('source_anchor')}` / `{item.get('source_type')}`",
|
||||
f"`{item.get('release_bucket')}`",
|
||||
f"`{item.get('lifecycle_status')}`",
|
||||
f"`{item.get('verification_required')}`",
|
||||
fmt_list(item.get("dependencies")),
|
||||
str(item.get("deferral_rationale") or "—").replace("|", "\\|"),
|
||||
] for item in lane_items]
|
||||
))
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("board_json", type=Path)
|
||||
parser.add_argument("board_md", type=Path)
|
||||
parser.add_argument("--check", action="store_true", help="fail if board_md is not up to date")
|
||||
args = parser.parse_args()
|
||||
|
||||
board = load_board(args.board_json)
|
||||
errors = validate_board(board)
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
rendered = render(board)
|
||||
if args.check:
|
||||
existing = args.board_md.read_text() if args.board_md.exists() else ""
|
||||
if existing != rendered:
|
||||
print(f"ERROR: {args.board_md} is not up to date", file=sys.stderr)
|
||||
return 1
|
||||
print(f"PASS: {args.board_md} is up to date and roadmap coverage is complete")
|
||||
return 0
|
||||
args.board_md.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.board_md.write_text(rendered)
|
||||
print(f"wrote {args.board_md}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
58
.omx/cc2/validate_issue_parity_intake.py
Executable file
58
.omx/cc2/validate_issue_parity_intake.py
Executable file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the worker-2 CC2 issue/parity intake fragment."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
INTAKE = ROOT / ".omx" / "cc2" / "issue-parity-intake.json"
|
||||
REQUIRED_ISSUES = set(range(3028, 3039)) | {3007, 3006, 3020, 3005, 3003, 2997, 3023, 3004}
|
||||
ALLOWED_STATUS = {
|
||||
"context",
|
||||
"active",
|
||||
"open",
|
||||
"done_verify",
|
||||
"stale_done",
|
||||
"superseded",
|
||||
"deferred_with_rationale",
|
||||
"rejected_not_claw",
|
||||
}
|
||||
ALLOWED_BUCKETS = {"alpha_blocker", "beta_adoption", "ga_ecosystem", "post_2_0_research"}
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise SystemExit(f"FAIL: {message}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
data = json.loads(INTAKE.read_text())
|
||||
issue_rows = data.get("issue_clusters", [])
|
||||
parity_rows = data.get("parity_rows", [])
|
||||
|
||||
seen = {row.get("source_number") for row in issue_rows}
|
||||
missing = sorted(REQUIRED_ISSUES - seen)
|
||||
extra = sorted(seen - REQUIRED_ISSUES)
|
||||
require(not missing, f"missing required issue rows: {missing}")
|
||||
require(not extra, f"unexpected issue rows in scoped intake: {extra}")
|
||||
require(len(issue_rows) == len(REQUIRED_ISSUES), "duplicate or missing issue row count")
|
||||
|
||||
ids = [row.get("id") for row in issue_rows + parity_rows]
|
||||
require(len(ids) == len(set(ids)), "duplicate ids present")
|
||||
|
||||
for row in issue_rows + parity_rows:
|
||||
row_id = row.get("id")
|
||||
for field in ["source_anchor", "source_type", "release_bucket", "lifecycle_status", "dependencies", "verification_required"]:
|
||||
require(row.get(field) not in (None, "", []), f"{row_id} missing {field}")
|
||||
require(row["release_bucket"] in ALLOWED_BUCKETS, f"{row_id} invalid release_bucket {row['release_bucket']}")
|
||||
require(row["lifecycle_status"] in ALLOWED_STATUS, f"{row_id} invalid lifecycle_status {row['lifecycle_status']}")
|
||||
if row["lifecycle_status"] == "deferred_with_rationale":
|
||||
require(row.get("deferral_rationale"), f"{row_id} deferred without rationale")
|
||||
|
||||
require(len(parity_rows) >= data["coverage"]["parity_rows_expected_minimum"], "not enough parity rows")
|
||||
print(f"PASS issue/parity intake: {len(issue_rows)} issue rows, {len(parity_rows)} parity rows")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
8
.port_sessions/b035f648d5b549aa836ea01f6727ec62.json
Normal file
8
.port_sessions/b035f648d5b549aa836ea01f6727ec62.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"session_id": "b035f648d5b549aa836ea01f6727ec62",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
9
.port_sessions/b234acb1eb8c486e80544ddc7e13e6d8.json
Normal file
9
.port_sessions/b234acb1eb8c486e80544ddc7e13e6d8.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"session_id": "b234acb1eb8c486e80544ddc7e13e6d8",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
9
.port_sessions/b67e062748f04e10ac5770df9285e4bd.json
Normal file
9
.port_sessions/b67e062748f04e10ac5770df9285e4bd.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"session_id": "b67e062748f04e10ac5770df9285e4bd",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
9
.port_sessions/bb88fd20433840a8b19237e3f306c6e3.json
Normal file
9
.port_sessions/bb88fd20433840a8b19237e3f306c6e3.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"session_id": "bb88fd20433840a8b19237e3f306c6e3",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- Frameworks: none detected from the supported starter markers.
|
||||
|
||||
## Verification
|
||||
- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
|
||||
- Run Rust verification from repo root: `scripts/fmt.sh --check`; for formatting use `scripts/fmt.sh`. Run Rust clippy/tests from `rust/`: `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
|
||||
- `src/` and `tests/` are both present; update both surfaces together when behavior changes.
|
||||
|
||||
## Repository shape
|
||||
|
||||
80
README.md
80
README.md
@@ -98,10 +98,87 @@ export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
|
||||
**Git Bash / WSL** are optional alternatives, not requirements. If you prefer bash-style paths (`/c/Users/you/...` instead of `C:\Users\you\...`), Git Bash (ships with Git for Windows) works well. In Git Bash, the `MINGW64` prompt is expected and normal — not a broken install.
|
||||
|
||||
## Post-build: locate the binary and verify
|
||||
|
||||
After running `cargo build --workspace`, the `claw` binary is built but **not** automatically installed to your system. Here's where to find it and how to verify the build succeeded.
|
||||
|
||||
### Binary location
|
||||
|
||||
After `cargo build --workspace` in `claw-code/rust/`:
|
||||
|
||||
**Debug build (default, faster compile):**
|
||||
- **macOS/Linux:** `rust/target/debug/claw`
|
||||
- **Windows:** `rust/target/debug/claw.exe`
|
||||
|
||||
**Release build (optimized, slower compile):**
|
||||
- **macOS/Linux:** `rust/target/release/claw`
|
||||
- **Windows:** `rust/target/release/claw.exe`
|
||||
|
||||
If you ran `cargo build` without `--release`, the binary is in the `debug/` folder.
|
||||
|
||||
### Verify the build succeeded
|
||||
|
||||
Test the binary directly using its path:
|
||||
|
||||
```bash
|
||||
# macOS/Linux (debug build)
|
||||
./rust/target/debug/claw --help
|
||||
./rust/target/debug/claw doctor
|
||||
|
||||
# Windows PowerShell (debug build)
|
||||
.\rust\target\debug\claw.exe --help
|
||||
.\rust\target\debug\claw.exe doctor
|
||||
```
|
||||
|
||||
If these commands succeed, the build is working. `claw doctor` is your first health check — it validates your API key, model access, and tool configuration.
|
||||
|
||||
### Optional: Add to PATH
|
||||
|
||||
If you want to run `claw` from any directory without the full path, choose one of these approaches:
|
||||
|
||||
**Option 1: Symlink (macOS/Linux)**
|
||||
```bash
|
||||
ln -s $(pwd)/rust/target/debug/claw /usr/local/bin/claw
|
||||
```
|
||||
Then reload your shell and test:
|
||||
```bash
|
||||
claw --help
|
||||
```
|
||||
|
||||
**Option 2: Use `cargo install` (all platforms)**
|
||||
|
||||
Build and install to Cargo's default location (`~/.cargo/bin/`, which is usually on PATH):
|
||||
```bash
|
||||
# From the claw-code/rust/ directory
|
||||
cargo install --path . --force
|
||||
|
||||
# Then from anywhere
|
||||
claw --help
|
||||
```
|
||||
|
||||
**Option 3: Update shell profile (bash/zsh)**
|
||||
|
||||
Add this line to `~/.bashrc` or `~/.zshrc`:
|
||||
```bash
|
||||
export PATH="$(pwd)/rust/target/debug:$PATH"
|
||||
```
|
||||
|
||||
Reload your shell:
|
||||
```bash
|
||||
source ~/.bashrc # or source ~/.zshrc
|
||||
claw --help
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- **"command not found: claw"** — The binary is in `rust/target/debug/claw`, but it's not on your PATH. Use the full path `./rust/target/debug/claw` or symlink/install as above.
|
||||
- **"permission denied"** — On macOS/Linux, you may need `chmod +x rust/target/debug/claw` if the executable bit isn't set (rare).
|
||||
- **Debug vs. release** — If the build is slow, you're in debug mode (default). Add `--release` to `cargo build` for faster runtime, but the build itself will take 5–10 minutes.
|
||||
|
||||
> [!NOTE]
|
||||
> **Auth:** claw requires an **API key** (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.) — Claude subscription login is not a supported auth path.
|
||||
|
||||
Run the workspace test suite:
|
||||
Run the workspace test suite after verifying the binary works:
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
@@ -115,6 +192,7 @@ cargo test --workspace
|
||||
- [`PARITY.md`](./PARITY.md) — parity status for the Rust port
|
||||
- [`rust/MOCK_PARITY_HARNESS.md`](./rust/MOCK_PARITY_HARNESS.md) — deterministic mock-service harness details
|
||||
- [`ROADMAP.md`](./ROADMAP.md) — active roadmap and open cleanup work
|
||||
- [`docs/g004-events-reports-contract.md`](./docs/g004-events-reports-contract.md) — Stream 2 lane event/report contract guidance for consumers
|
||||
- [`PHILOSOPHY.md`](./PHILOSOPHY.md) — why the project exists and how it is operated
|
||||
|
||||
## Ecosystem
|
||||
|
||||
1375
ROADMAP.md
1375
ROADMAP.md
File diff suppressed because one or more lines are too long
99
USAGE.md
99
USAGE.md
@@ -52,6 +52,26 @@ cd rust
|
||||
|
||||
**Note:** Diagnostic verbs (`doctor`, `status`, `sandbox`, `version`) support `--output-format json` for machine-readable output. Invalid suffix arguments (e.g., `--json`) are now rejected at parse time rather than falling through to prompt dispatch.
|
||||
|
||||
### Initialize a repository
|
||||
|
||||
Set up a new repository with `.claw` config, `.claw.json`, `.gitignore` entries, and a `CLAUDE.md` guidance file:
|
||||
|
||||
```bash
|
||||
cd /path/to/your/repo
|
||||
./target/debug/claw init
|
||||
```
|
||||
|
||||
Text mode (human-readable) shows artifact creation summary with project path and next steps. Idempotent — running multiple times in the same repo marks already-created files as "skipped".
|
||||
|
||||
JSON mode for scripting:
|
||||
```bash
|
||||
./target/debug/claw init --output-format json
|
||||
```
|
||||
|
||||
Returns structured output with `project_path`, `created[]`, `updated[]`, `skipped[]` arrays (one per artifact), and `artifacts[]` carrying each file's `name` and machine-stable `status` tag. The legacy `message` field preserves backward compatibility.
|
||||
|
||||
**Why structured fields matter:** Claws can detect per-artifact state (`created` vs `updated` vs `skipped`) without substring-matching human prose. Use the `created[]`, `updated[]`, and `skipped[]` arrays for conditional follow-up logic (e.g., only commit if files were actually created, not just updated).
|
||||
|
||||
### Interactive REPL
|
||||
|
||||
```bash
|
||||
@@ -80,6 +100,85 @@ cd rust
|
||||
./target/debug/claw --output-format json prompt "status"
|
||||
```
|
||||
|
||||
### Inspect worker state
|
||||
|
||||
The `claw state` command reads `.claw/worker-state.json`, which is written by the interactive REPL or a one-shot prompt when a worker executes a task. This file contains the worker ID, session reference, model, and permission mode.
|
||||
|
||||
Prerequisite: You must run `claw` (interactive REPL) or `claw prompt <text>` at least once in the repository to produce the worker state file.
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
./target/debug/claw state
|
||||
```
|
||||
|
||||
JSON mode:
|
||||
```bash
|
||||
./target/debug/claw state --output-format json
|
||||
```
|
||||
|
||||
If you run `claw state` before any worker has executed, you will see a helpful error:
|
||||
```
|
||||
error: no worker state file found at .claw/worker-state.json
|
||||
Hint: worker state is written by the interactive REPL or a non-interactive prompt.
|
||||
Run: claw # start the REPL (writes state on first turn)
|
||||
Or: claw prompt <text> # run one non-interactive turn
|
||||
Then rerun: claw state [--output-format json]
|
||||
```
|
||||
|
||||
## Advanced slash commands (Interactive REPL only)
|
||||
|
||||
These commands are available inside the interactive REPL (`claw` with no args). They extend the assistant with workspace analysis, planning, and navigation features.
|
||||
|
||||
### `/ultraplan` — Deep planning with multi-step reasoning
|
||||
|
||||
**Purpose:** Break down a complex task into steps using extended reasoning.
|
||||
|
||||
```bash
|
||||
# Start the REPL
|
||||
claw
|
||||
|
||||
# Inside the REPL
|
||||
/ultraplan refactor the auth module to use async/await
|
||||
/ultraplan design a caching layer for database queries
|
||||
/ultraplan analyze this module for performance bottlenecks
|
||||
```
|
||||
|
||||
Output: A structured plan with numbered steps, reasoning for each step, and expected outcomes. Use this when you want the assistant to think through a problem in detail before coding.
|
||||
|
||||
### `/teleport` — Jump to a file or symbol
|
||||
|
||||
**Purpose:** Quickly navigate to a file, function, class, or struct by name.
|
||||
|
||||
```bash
|
||||
# Jump to a symbol
|
||||
/teleport UserService
|
||||
/teleport authenticate_user
|
||||
/teleport RequestHandler
|
||||
|
||||
# Jump to a file
|
||||
/teleport src/auth.rs
|
||||
/teleport crates/runtime/lib.rs
|
||||
/teleport ./ARCHITECTURE.md
|
||||
```
|
||||
|
||||
Output: The file content, with the requested symbol highlighted or the file fully loaded. Useful for exploring the codebase without manually navigating directories. If multiple matches exist, the assistant shows the top candidates.
|
||||
|
||||
### `/bughunter` — Scan for likely bugs and issues
|
||||
|
||||
**Purpose:** Analyze code for common pitfalls, anti-patterns, and potential bugs.
|
||||
|
||||
```bash
|
||||
# Scan the entire workspace
|
||||
/bughunter
|
||||
|
||||
# Scan a specific directory or file
|
||||
/bughunter src/handlers
|
||||
/bughunter rust/crates/runtime
|
||||
/bughunter src/auth.rs
|
||||
```
|
||||
|
||||
Output: A list of suspicious patterns with explanations (e.g., "unchecked unwrap()", "potential race condition", "missing error handling"). Each finding includes the file, line number, and suggested fix. Use this as a first pass before a full code review.
|
||||
|
||||
## Model and permission controls
|
||||
|
||||
```bash
|
||||
|
||||
185
docs/g002-security-verification-map.md
Normal file
185
docs/g002-security-verification-map.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# G002 alpha security map and verification plan
|
||||
|
||||
Generated by `worker-4` for OMX team task 5 on 2026-05-14.
|
||||
|
||||
## Scope and coordination
|
||||
|
||||
- Active goal context: `G002-alpha-security` / Stream 6 day-one security and permissions gate.
|
||||
- Worker ownership: `worker-1` owns minimal implementation changes for workspace/path enforcement. `worker-4` owns this repository map, integration verification plan, changed-file/commit report, and exact verification evidence.
|
||||
- Boundary: this report does not mutate `.omx/ultragoal` and does not edit shared security/path tests.
|
||||
- Parallel probe status: three native subagents were spawned for repository map, test probe, and change-slice probe, but all failed before returning findings with `429 Too Many Requests`; local mapping below is based on direct repository inspection.
|
||||
|
||||
## Current permission and path enforcement map
|
||||
|
||||
### Runtime permission policy and enforcer
|
||||
|
||||
- `rust/crates/runtime/src/permissions.rs`
|
||||
- Owns the `PermissionMode` ordering and `PermissionPolicy` authorization contract.
|
||||
- Existing tests cover read-only denial, workspace-write escalation, prompt approvals/denials, danger-full-access allowance, override recording, and required-mode reporting.
|
||||
- Integration risk: any new dynamic file/path rule must preserve the existing `PermissionPolicy::authorize` semantics so prompt/override audit events remain stable.
|
||||
|
||||
- `rust/crates/runtime/src/permission_enforcer.rs`
|
||||
- `PermissionEnforcer::check`, `check_with_required_mode`, `check_file_write`, and `check_bash` convert policy outcomes into structured `EnforcementResult` payloads.
|
||||
- `check_file_write` currently has the direct write gate for workspace-write mode.
|
||||
- `is_within_workspace` is a string-prefix boundary check after simple relative-path joining; it does not canonicalize symlinks, `..`, Windows drive prefixes, or case variants.
|
||||
- Existing tests cover read-only denial, workspace-write inside/outside paths, trailing slashes, root equality, bash read-only heuristics, prompt-mode denial payloads, and structured denied fields.
|
||||
|
||||
### File tool path handling
|
||||
|
||||
- `rust/crates/runtime/src/file_ops.rs`
|
||||
- `read_file`, `write_file`, and `edit_file` normalize paths before filesystem operations but do not themselves require a workspace root.
|
||||
- `read_file_in_workspace`, `write_file_in_workspace`, and `edit_file_in_workspace` exist as boundary-enforced wrappers.
|
||||
- `validate_workspace_boundary` canonicalizes through the caller-provided resolved path and checks `starts_with(workspace_root)`.
|
||||
- `is_symlink_escape` detects direct symlink escapes by comparing canonical target to canonical workspace root.
|
||||
- Search tools (`glob_search`, `grep_search`) derive walk roots and prune heavy directories, but they are separate from the write enforcement path.
|
||||
- Existing tests cover oversized/binary reads, workspace-boundary read rejection, symlink escape detection, glob brace expansion, ignored directories, and grep/glob behavior.
|
||||
|
||||
### Bash command validation
|
||||
|
||||
- `rust/crates/runtime/src/bash_validation.rs`
|
||||
- `validate_command` runs mode validation, sed validation, destructive warning checks, then path validation.
|
||||
- `validate_read_only` blocks write-like commands, state-modifying commands, write redirects, and mutating git subcommands in read-only mode.
|
||||
- `validate_mode` warns when workspace-write commands appear to target hard-coded system paths.
|
||||
- `validate_paths` warns for `../`, `~/`, and `$HOME` references; it is intentionally heuristic and does not resolve shell expansion or canonical targets.
|
||||
- Existing tests cover read-only blockers, destructive warnings, sed in-place blocking, path traversal/home warnings, command classification, and full pipeline allow/block/warn outcomes.
|
||||
|
||||
### Sandbox and diagnostics surfaces
|
||||
|
||||
- `rust/crates/runtime/src/sandbox.rs`
|
||||
- Owns container/sandbox status detection and workspace-only sandbox command construction.
|
||||
- Relevant for day-one security because sandbox status must not overstate filesystem isolation.
|
||||
|
||||
- `rust/crates/rusty-claude-cli/src/main.rs`
|
||||
- Owns CLI permission-mode parsing, direct JSON/text diagnostic output, `/permissions`, `/status`, `/doctor`, and command dispatch paths.
|
||||
- Existing CLI integration tests under `rust/crates/rusty-claude-cli/tests/` cover permission prompt scenarios and output-format contracts.
|
||||
|
||||
- `rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`
|
||||
- End-to-end harness includes `bash_permission_prompt_approved`, `bash_permission_prompt_denied`, read/write file allow/deny, and plugin workspace-write scenarios.
|
||||
|
||||
## Existing G002-adjacent coverage
|
||||
|
||||
- Unit-level permission coverage:
|
||||
- `cargo test -p runtime permissions::tests`
|
||||
- `cargo test -p runtime permission_enforcer::tests`
|
||||
- `cargo test -p runtime bash_validation::tests`
|
||||
- `cargo test -p runtime file_ops::tests`
|
||||
|
||||
- CLI and integration coverage:
|
||||
- `cargo test -p rusty-claude-cli --test mock_parity_harness`
|
||||
- `cargo test -p rusty-claude-cli --test output_format_contract`
|
||||
- `cargo test -p rusty-claude-cli --test cli_flags_and_config_defaults`
|
||||
|
||||
- Board/report validation coverage:
|
||||
- `python3 scripts/validate_cc2_board.py --board .omx/cc2/board.json`
|
||||
- `python3 .omx/cc2/validate_issue_parity_intake.py .omx/cc2/issue-parity-intake.json`
|
||||
|
||||
## Recommended safe work slices
|
||||
|
||||
### Implementation lane (owned by worker-1 unless re-scoped)
|
||||
|
||||
1. Replace string-prefix workspace boundary checks with canonical path comparison in the runtime enforcement path.
|
||||
- Primary files: `rust/crates/runtime/src/permission_enforcer.rs`, possibly shared helper extraction from `rust/crates/runtime/src/file_ops.rs`.
|
||||
- Regression cases: `../` traversal, symlink escape, root prefix collision (`/workspace` vs `/workspacex`), relative paths, trailing slash root equality.
|
||||
|
||||
2. Ensure direct file tools call workspace-aware wrappers when active permission mode is `workspace-write`.
|
||||
- Primary files: likely `rust/crates/runtime/src/mcp_tool_bridge.rs` and/or the runtime tool execution bridge that calls `file_ops`.
|
||||
- Regression cases: direct read/write paths, missing parent creation, symlink parent escape, and error payload stability.
|
||||
|
||||
3. Keep bash validation as a warning/classification layer unless a real shell-expansion resolver is introduced.
|
||||
- Primary files: `rust/crates/runtime/src/bash_validation.rs`, `rust/crates/runtime/src/bash.rs`.
|
||||
- Risk: heuristic parsing cannot faithfully resolve shell expansion, globs, aliases, or platform-specific path rules; avoid claiming hard enforcement unless execution sandbox or command resolver proves it.
|
||||
|
||||
### Test lane (coordinate with worker-3/worker-1 before editing)
|
||||
|
||||
1. Add unit regressions close to each enforcement function before changing behavior.
|
||||
- `permission_enforcer.rs`: canonical path boundary and Windows-shaped path cases.
|
||||
- `file_ops.rs`: write/edit workspace wrappers with symlink parent escapes and missing file parent canonicalization.
|
||||
- `bash_validation.rs`: shell expansion/glob/path warnings remain warnings unless a resolver is introduced.
|
||||
|
||||
2. Add at least one integration test proving the runtime bridge actually routes file tools through workspace enforcement, not only helper functions.
|
||||
- Candidate: `rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs` for direct write denial and no file created outside workspace.
|
||||
|
||||
3. Preserve existing prompt/event visibility tests.
|
||||
- Candidate surfaces: permission prompt scenarios in `mock_parity_harness.rs`, status/doctor JSON in `output_format_contract.rs`.
|
||||
|
||||
### Docs/reporting lane (owned by worker-4)
|
||||
|
||||
1. Keep this file as the integration handoff artifact for G002 mapping and verification.
|
||||
2. Report changed files and commits relative to `origin/main` so the leader can integrate worker branches deterministically.
|
||||
3. Include exact command evidence in the task lifecycle result.
|
||||
|
||||
## Changed files relative to `origin/main` at map time
|
||||
|
||||
The worktree currently contains these files added relative to `origin/main` before this task report:
|
||||
|
||||
- `.omx/cc2/board.json`
|
||||
- `.omx/cc2/board.md`
|
||||
- `.omx/cc2/issue-parity-intake.json`
|
||||
- `.omx/cc2/issue-parity-intake.md`
|
||||
- `.omx/cc2/render_board_md.py`
|
||||
- `.omx/cc2/validate_issue_parity_intake.py`
|
||||
- `scripts/cc2_board.py`
|
||||
- `scripts/generate_cc2_board.py`
|
||||
- `scripts/validate_cc2_board.py`
|
||||
|
||||
This task adds:
|
||||
|
||||
- `docs/g002-security-verification-map.md`
|
||||
|
||||
## Commits relative to `origin/main` at map time
|
||||
|
||||
- `8311655` — `omx(team): auto-checkpoint worker-1 [1]`
|
||||
- `c6e2a7d` — `omx(team): merge worker-1`
|
||||
- `481585f` — `omx(team): auto-checkpoint worker-1 [1]`
|
||||
- `74bbf4b` — `omx(team): auto-checkpoint worker-4 [unknown]`
|
||||
- `5c77896` — `omx(team): auto-checkpoint worker-1 [1]`
|
||||
- `07dad88` — `Classify issue and parity intake for CC2 board integration`
|
||||
- `424825f` — `task: G001 human board and docs rendering`
|
||||
- `d15268e` — `Create a canonical CC2 board so every frozen ROADMAP heading is verifiably mapped`
|
||||
- `45b43b5` — `Make the CC2 board schema executable for G001`
|
||||
|
||||
## Verification checklist for leader integration
|
||||
|
||||
Run these from the repository root unless noted:
|
||||
|
||||
1. Python board/schema validation:
|
||||
- `python3 scripts/validate_cc2_board.py --board .omx/cc2/board.json`
|
||||
- `python3 .omx/cc2/validate_issue_parity_intake.py .omx/cc2/issue-parity-intake.json`
|
||||
|
||||
2. Rust formatting and lint/type checks:
|
||||
- `scripts/fmt.sh --check`
|
||||
- `(cd rust && cargo check --workspace)`
|
||||
- `(cd rust && cargo clippy --workspace --all-targets -- -D warnings)`
|
||||
|
||||
3. Targeted G002 security tests:
|
||||
- `(cd rust && cargo test -p runtime permissions::tests permission_enforcer::tests bash_validation::tests file_ops::tests)`
|
||||
- `(cd rust && cargo test -p rusty-claude-cli --test mock_parity_harness)`
|
||||
|
||||
4. Full regression:
|
||||
- `(cd rust && cargo test --workspace)`
|
||||
|
||||
|
||||
## Worker-4 verification evidence (2026-05-14)
|
||||
|
||||
PASS:
|
||||
|
||||
- `python3 scripts/validate_cc2_board.py --board .omx/cc2/board.json` → `PASS cc2 board validation`; 729 items; ROADMAP headings `124/124`; ROADMAP actions `542/542`.
|
||||
- `python3 .omx/cc2/validate_issue_parity_intake.py .omx/cc2/issue-parity-intake.json` → `PASS issue/parity intake: 19 issue rows, 9 parity rows`.
|
||||
- `scripts/fmt.sh --check` → no output and zero exit before Rust checks continued.
|
||||
- `(cd rust && cargo check --workspace)` → `Finished dev profile` successfully.
|
||||
- `(cd rust && cargo test -p runtime permissions::tests)` → 9 passed.
|
||||
- `(cd rust && cargo test -p runtime permission_enforcer::tests)` → 21 passed.
|
||||
- `(cd rust && cargo test -p runtime bash_validation::tests)` → 32 passed.
|
||||
- `(cd rust && cargo test -p runtime file_ops::tests)` → 14 passed.
|
||||
- `(cd rust && cargo test -p rusty-claude-cli --test mock_parity_harness)` → 1 passed.
|
||||
|
||||
FAIL / integration blockers observed on this worktree:
|
||||
|
||||
- `(cd rust && cargo clippy --workspace --all-targets -- -D warnings)` failed in existing runtime code, not this docs-only task:
|
||||
- `rust/crates/runtime/src/compact.rs:215` / `:216`: `clippy::match_same_arms`.
|
||||
- `rust/crates/runtime/src/policy_engine.rs:5`: `clippy::duration-suboptimal-units`.
|
||||
- `rust/crates/runtime/src/sandbox.rs:295-302`: `clippy::map_unwrap_or`.
|
||||
- `(cd rust && cargo test --workspace)` failed after broad success in API/commands/plugins/runtime tests because `rusty-claude-cli` unit test `tests::session_lifecycle_prefers_running_process_over_idle_shell` asserted `RunningProcess` but observed `IdleShell`.
|
||||
- Rerun of the specific failing test confirmed deterministic failure: `(cd rust && cargo test -p rusty-claude-cli --bin claw tests::session_lifecycle_prefers_running_process_over_idle_shell -- --exact --nocapture)` → 0 passed, 1 failed with the same `IdleShell` vs `RunningProcess` assertion.
|
||||
|
||||
Recommended owner for failures: not `worker-4` unless re-scoped. These failures are outside the docs/report artifact and touch shared runtime/CLI implementation files.
|
||||
96
docs/g003-boot-session-verification-map.md
Normal file
96
docs/g003-boot-session-verification-map.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# G003 boot/session/preflight verification map
|
||||
|
||||
Generated by `worker-1` for OMX team task 2 on 2026-05-14.
|
||||
|
||||
## Scope and coordination
|
||||
|
||||
- Active goal context: `G003-boot-session` / Stream 1 reliable worker boot and session control.
|
||||
- Boundary: this artifact is an audit/integration map only. It does not mutate `.omx/ultragoal` and it does not change shared implementation or tests.
|
||||
- Current worker split from leader mailbox:
|
||||
- `worker-1`: task 1 worker boot / prompt SLA plus this task 2 audit map.
|
||||
- `worker-2`: default trusted roots / trust resolver.
|
||||
- `worker-3`: startup-no-evidence classifier.
|
||||
- `worker-4`: session control plus preflight/doctor JSON surfaces.
|
||||
- Native subagent probes were attempted for Task 2 (`test probe` and `debug/root-cause probe`) but both failed before returning findings with `429 Too Many Requests`; the map below is based on direct repository inspection.
|
||||
|
||||
## Implementation surface map
|
||||
|
||||
### Worker boot lifecycle and prompt SLA
|
||||
|
||||
- `rust/crates/runtime/src/worker_boot.rs`
|
||||
- Core state types: `WorkerStatus`, `WorkerFailureKind`, `WorkerEventKind`, `WorkerEventPayload`, `StartupFailureClassification`, `StartupEvidenceBundle`, `WorkerTaskReceipt`, and `WorkerReadySnapshot`.
|
||||
- Control plane: `WorkerRegistry::{create,get,observe,resolve_trust,send_prompt,await_ready,restart,terminate,observe_completion,observe_startup_timeout}`.
|
||||
- Lifecycle states currently covered in code: `spawning`, `trust_required`, `tool_permission_required`, `ready_for_prompt`, `running`, `finished`, and `failed`.
|
||||
- Prompt delivery semantics currently use `Running` events and fields `prompt_in_flight`, `last_prompt`, `expected_receipt`, `replay_prompt`, and `prompt_delivery_attempts`.
|
||||
- Startup-no-evidence surface: `observe_startup_timeout` builds `StartupEvidenceBundle` and classifies trust, tool permission, prompt acceptance timeout, prompt misdelivery, transport death, worker crash, or unknown.
|
||||
- File observability surface: `emit_state_file` writes `.claw/worker-state.json` with status, readiness, trust state, prompt-in-flight flag, last event, and update age.
|
||||
|
||||
- `rust/crates/tools/src/lib.rs`
|
||||
- Tool APIs expose the worker control plane through `WorkerCreate`, `WorkerGet`, `WorkerObserve`, `WorkerResolveTrust`, `WorkerAwaitReady`, `WorkerSendPrompt`, `WorkerRestart`, `WorkerTerminate`, and `WorkerObserveCompletion`.
|
||||
- `WorkerCreate` merges `ConfigLoader::trusted_roots()` with per-call `trusted_roots` before calling `WorkerRegistry::create`.
|
||||
- Tool-level tests exercise worker create/observe/send/restart/terminate/completion and state-file transitions.
|
||||
|
||||
### Trust resolver and default trusted roots
|
||||
|
||||
- `rust/crates/runtime/src/trust_resolver.rs`
|
||||
- `TrustConfig`, `TrustAllowlistEntry`, and `TrustResolver` model trust prompts, allowlist/denylist policy, auto-trust, manual approval, and emitted trust events.
|
||||
- `path_matches_trusted_root` and internal `path_matches` canonicalize paths when possible.
|
||||
- Hazard: prefix matching must avoid accidental sibling matches such as `/tmp/work` matching `/tmp/work-evil`; worker-2 owns any changes here.
|
||||
|
||||
- `rust/crates/runtime/src/config.rs`
|
||||
- `trustedRoots` is parsed by `parse_optional_trusted_roots` and exposed through `RuntimeConfig::trusted_roots()` / feature config accessors.
|
||||
- Current default is empty when unset; any project default roots work belongs to worker-2.
|
||||
|
||||
### Session control
|
||||
|
||||
- `rust/crates/runtime/src/session_control.rs`
|
||||
- `SessionStore` namespaces sessions by canonical workspace fingerprint.
|
||||
- Key API: `from_cwd`, `from_data_dir`, `create_handle`, `resolve_reference`, `resolve_managed_path`, `list_sessions`, `latest_session`, `load_session`, and `fork_session`.
|
||||
- Guardrail: `validate_loaded_session` rejects cross-workspace sessions and allows legacy sessions only when their path remains inside the current workspace.
|
||||
- Worker-4 owns changes to this lane.
|
||||
|
||||
### CLI doctor/status/preflight and bootstrap-adjacent surfaces
|
||||
|
||||
- `rust/crates/commands/src/lib.rs`
|
||||
- Slash command definitions include `/status`, `/sandbox`, and `/doctor`.
|
||||
- JSON rendering for command surfaces exists through handler functions and tests in the same module.
|
||||
|
||||
- `rust/crates/tools/src/lib.rs`
|
||||
- Bash and PowerShell tool runners include `workspace_test_branch_preflight`, which returns structured output with `return_code_interpretation: preflight_blocked:branch_divergence` for broad workspace tests on stale branches.
|
||||
- Tests around `bash_workspace_tests_are_blocked_when_branch_is_behind_main` and targeted-test skipping protect this preflight behavior.
|
||||
|
||||
## Existing focused verification commands
|
||||
|
||||
Run from `rust/` unless noted.
|
||||
|
||||
- Worker boot runtime contract:
|
||||
- `cargo test -p runtime worker_boot -- --nocapture`
|
||||
- Worker tool API contract:
|
||||
- `cargo test -p tools worker_ -- --nocapture`
|
||||
- Session control contract:
|
||||
- `cargo test -p runtime session_control -- --nocapture`
|
||||
- Trust resolver/config trusted roots:
|
||||
- `cargo test -p runtime trust_resolver -- --nocapture`
|
||||
- `cargo test -p runtime config::tests::parses_trusted_roots_from_settings config::tests::trusted_roots_default_is_empty_when_unset -- --nocapture`
|
||||
- Preflight/tool branch guardrails:
|
||||
- `cargo test -p tools bash_workspace_tests_are_blocked_when_branch_is_behind_main bash_targeted_tests_skip_branch_preflight -- --nocapture`
|
||||
- Formatting/type/lint baseline:
|
||||
- `../scripts/fmt.sh --check`
|
||||
- `cargo check -p runtime -p tools -p commands`
|
||||
- `cargo clippy -p runtime -p tools -p commands --all-targets --no-deps -- -D warnings`
|
||||
|
||||
## Gaps and hazards for leader integration
|
||||
|
||||
- Prompt SLA event naming is partially implicit: `send_prompt` emits `WorkerEventKind::Running`; it does not expose separate `prompt.sent`, `prompt.accepted`, `prompt.acceptance_delayed`, or `prompt.acceptance_timeout` event names. The current equivalent evidence is `prompt_in_flight`, `Running`, `observe_completion`, and startup-timeout classification.
|
||||
- `StartupFailureClassification::PromptAcceptanceTimeout` is covered in `worker_boot` tests; full terminal/transport integration should still be verified by the leader or worker-3 if a real pane watcher exists outside the in-memory registry.
|
||||
- Default trusted roots are parsed and merged into `WorkerCreate`, but unset config currently means no default roots. Worker-2 owns any change to default root selection.
|
||||
- Session control protects workspace fingerprints at load/fork time; worker-4 owns CLI/doctor/preflight JSON contract changes.
|
||||
- Full-workspace clippy currently has known unrelated runtime findings observed during task 1 verification; do not block this docs-only map on those unless leader re-scopes cleanup.
|
||||
|
||||
## Recommended safe integration order
|
||||
|
||||
1. Integrate worker boot / prompt SLA changes first and run `cargo test -p runtime worker_boot -- --nocapture` plus `cargo test -p tools worker_ -- --nocapture`.
|
||||
2. Integrate trust-root changes and rerun trust/config tests plus the worker create config merge test.
|
||||
3. Integrate startup-no-evidence classifier changes and rerun `cargo test -p runtime worker_boot -- --nocapture`.
|
||||
4. Integrate session control / preflight / doctor JSON changes and rerun session-control, commands JSON, and preflight tests.
|
||||
5. Run final formatting, targeted cargo check/clippy, then broader workspace tests with known full-workspace failures documented separately.
|
||||
67
docs/g004-events-reports-contract.md
Normal file
67
docs/g004-events-reports-contract.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# G004 event and report contract guidance
|
||||
|
||||
Captured: 2026-05-14 during the Stream 2 `G004-events-reports` team run.
|
||||
|
||||
Purpose: keep the user/developer-facing contract guidance for ROADMAP Phase 2 in one tracked source that points back to the code and roadmap anchors. This document is intentionally not the implementation map for task 5; it describes the interoperability contract consumers should rely on as the lane-event, report-schema, approval-token, and capability-negotiation lanes land.
|
||||
|
||||
## Source-of-truth anchors
|
||||
|
||||
| Contract family | Roadmap anchor | Current implementation / owner-facing anchor | Consumer guidance |
|
||||
| --- | --- | --- | --- |
|
||||
| Canonical lane events | `ROADMAP.md` Phase 2 §4, §4.5, §4.6, §4.7 | `rust/crates/runtime/src/lane_events.rs` (`LaneEventName`, `LaneEventStatus`, `LaneEventMetadata`, terminal reconciliation helpers) | Consume `event`, `status`, `emittedAt`, and `metadata` fields as the canonical state stream; do not infer lane state from terminal text when a structured event is present. |
|
||||
| Report schema v1 and projections | `ROADMAP.md` §4.25-§4.34 | Stream 2 report-schema lane / fixtures as they land | Treat a report as a versioned canonical payload plus derived projections. A projection may omit or transform fields only with explicit provenance: compatibility downgrade, redaction policy, truncation, or source absence. |
|
||||
| Policy-blocked handoff and approval-token chain | `ROADMAP.md` §4.37-§4.39 | Stream 2 approval-token lane as it lands | Treat policy blocks and owner approvals as typed artifacts, not prose. Execute an exception only when the approval token matches actor, policy, action, repo/branch/commit scope, expiry, and one-time-use state. |
|
||||
| Capability negotiation | `ROADMAP.md` §4.25, §4.26, §4.32, §4.34 | Report-schema/projection fixtures and consumer conformance cases as they land | Consumers must advertise supported schema versions, optional field families, projection views, redaction semantics, and downgrade handling before relying on reduced payloads. |
|
||||
|
||||
## Lane event contract
|
||||
|
||||
The lane-event stream is the first machine-trustworthy surface for Stream 2. Consumers should expect these invariants when reading `LaneEvent` payloads:
|
||||
|
||||
- `event` is a typed event name, currently including the core lane lifecycle (`lane.started`, `lane.ready`, `lane.blocked`, `lane.red`, `lane.green`, `lane.finished`, `lane.failed`), branch health (`branch.stale_against_main`, `branch.workspace_mismatch`), reconciliation (`lane.reconciled`, `lane.superseded`, `lane.closed`), and ship provenance (`ship.prepared`, `ship.commits_selected`, `ship.merged`, `ship.pushed_main`).
|
||||
- `status` is the normalized state for the event; consumers should prefer it over freeform `detail` text for automation.
|
||||
- `metadata.seq`, `metadata.timestamp_ms`, and terminal fingerprints are the ordering/deduplication hooks. Consumers should use terminal reconciliation output rather than double-reporting contradictory terminal bursts.
|
||||
- `metadata.provenance`, `metadata.environment_label`, `metadata.emitter_identity`, and `metadata.confidence_level` tell consumers whether an event is live lane truth, test traffic, healthcheck/replay output, or transport-layer evidence.
|
||||
- `metadata.session_identity` and `metadata.ownership` bind a lane event to the session, workspace, workflow scope, owner, and watcher action. A watcher should not act on events whose ownership says `observe` or `ignore`.
|
||||
|
||||
Minimal consumer rule: if a structured event exists, pane text is supporting evidence only. Pane scraping must not override a higher-confidence typed event with matching session/workflow ownership.
|
||||
|
||||
## Report schema v1 contract
|
||||
|
||||
A Stream 2 report should be treated as a canonical fact record with optional projections. Consumers should preserve these semantics even when they receive only a downgraded view:
|
||||
|
||||
- Every report payload declares a schema version and a stable report identity/content hash for the full-fidelity canonical payload.
|
||||
- Assertions are labeled as `fact`, `hypothesis`, or another declared evidence class, with confidence and source references. Negative evidence is first-class: `not observed`, `checked and absent`, and `redacted` are distinct states.
|
||||
- Field deltas name the field, previous value/state, new value/state, attribution, and whether the delta came from source content, projection, downgrade, or redaction policy.
|
||||
- Projections carry lineage back to the canonical report id/content hash and name the projection view, capability set, schema version, redaction policy, and deterministic rendering inputs.
|
||||
- Redaction provenance is explicit. A missing field without a redaction/downgrade/source-absence reason is not enough evidence for an automated consumer to conclude the underlying fact is absent.
|
||||
|
||||
Minimal consumer rule: store the canonical identity and projection metadata together. Do not compare two projections as state changes unless their canonical content hash or declared projection inputs differ.
|
||||
|
||||
## Approval-token and policy-blocked contract
|
||||
|
||||
Policy-blocked actions and owner-approved exceptions belong in the same structured event/report family:
|
||||
|
||||
- A policy block names the typed reason, policy source, actor scope, blocked action, and safe fallback path.
|
||||
- An approval token names the approving actor, policy exception, action, repository/worktree/branch/commit scope, expiry, and allowed use count.
|
||||
- Token consumption records the exact action and scope that spent the token. Replays, scope expansion, expired tokens, and revoked tokens should surface typed policy errors.
|
||||
- Delegation traceability stays attached when another worker/lane executes the approved action; the executor must be able to prove which approval artifact authorized the exception.
|
||||
|
||||
Minimal consumer rule: prose such as "approved" is not an executable approval. Require the structured token and verify that it is unconsumed and scoped to the exact action before proceeding.
|
||||
|
||||
## Capability negotiation and conformance
|
||||
|
||||
Mixed-version consumers are expected during Stream 2 rollout. Producers and consumers should negotiate instead of silently dropping fields:
|
||||
|
||||
- Consumers advertise supported report schema versions, field families, projection views, redaction states, downgrade semantics, and fixture/conformance suite version.
|
||||
- Producers preserve one canonical full-fidelity report and emit downgraded projections only with `downgraded_for_compatibility` metadata.
|
||||
- Deterministic projection inputs include schema version, consumer capability set, projection policy version, redaction policy version, and canonical content hash.
|
||||
- Consumer conformance should distinguish syntax acceptance from semantic correctness, especially for `redacted` vs `missing`, stale vs current projections, negative evidence, and approval-token replay states.
|
||||
|
||||
Minimal consumer rule: an older consumer may accept a downgraded projection, but it must surface the downgrade as a capability limitation rather than treating omitted fields as canonical absence.
|
||||
|
||||
## Documentation maintenance rules
|
||||
|
||||
- Keep ROADMAP Phase 2 as the product requirement source and this file as the contract-reading guide.
|
||||
- Keep Rust type names and event names aligned with `rust/crates/runtime/src/lane_events.rs`; update this document in the same change when public event names or metadata semantics change.
|
||||
- Keep report-schema examples/fixtures aligned with this guide once the schema lane lands; fixture updates should explain intentional schema or projection changes.
|
||||
- Do not mutate `.omx/ultragoal` from worker lanes. Leader-owned Ultragoal checkpointing consumes commits and verification evidence from task results.
|
||||
57
docs/g004-events-reports-verification-map.md
Normal file
57
docs/g004-events-reports-verification-map.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# G004 events/reports verification map
|
||||
|
||||
Scope source: OMX team `g004-events-reports-u-e61d2271`, worker-1 tasks 1, 2, 4, 5. Workers must not mutate `.omx/ultragoal`; leader owns aggregate checkpoints.
|
||||
|
||||
## Ownership boundaries
|
||||
|
||||
- **Lane events / event identity / terminal reconciliation** — `rust/crates/runtime/src/lane_events.rs`, exported through `rust/crates/runtime/src/lib.rs`; tool-manifest consumers in `rust/crates/tools/src/lib.rs` write `LaneEvent` vectors.
|
||||
- **Report schema v1 / projection / redaction / capability negotiation** — `rust/crates/runtime/src/report_schema.rs`, exported through `rust/crates/runtime/src/lib.rs`; fixture note at `rust/crates/runtime/tests/fixtures/report_schema_v1/README.md`.
|
||||
- **Approval-token chain** — ROADMAP §§4.38-4.40; owned by worker-2 for this team split. Worker-1 did not edit it.
|
||||
- **Pinpoint closure batch** — runtime hygiene across compact/search-parser/policy/sandbox/integration-test surfaces: `rust/crates/runtime/src/compact.rs`, `rust/crates/runtime/src/file_ops.rs`, `rust/crates/runtime/src/policy_engine.rs`, `rust/crates/runtime/src/sandbox.rs`, `rust/crates/runtime/tests/integration_tests.rs`.
|
||||
- **Regression harness / docs alignment** — worker-3/worker-4 lanes per leader split. Coordinate before editing shared docs/tests.
|
||||
|
||||
## Relevant symbols and files
|
||||
|
||||
- `LaneEventName`, `LaneEventStatus`, `LaneEventMetadata`, `LaneEventBuilder`, `compute_event_fingerprint`, `dedupe_terminal_events`, `reconcile_terminal_events` in `runtime/src/lane_events.rs`.
|
||||
- `CanonicalReportV1`, `ReportClaim`, `NegativeEvidence`, `FieldDelta`, `ConsumerCapabilities`, `ReportProjectionV1`, `canonicalize_report`, `project_report`, `report_schema_v1_registry` in `runtime/src/report_schema.rs`.
|
||||
- `AgentOutput.lane_events`, `persist_agent_terminal_state`, `write_agent_manifest`, `maybe_commit_provenance` in `tools/src/lib.rs`.
|
||||
- Search/parser closure helpers: `summarize_messages` in `compact.rs`, `grep_search_impl` / `build_grep_content_output` in `file_ops.rs`.
|
||||
|
||||
## Completed worker-1 commits
|
||||
|
||||
- `f45f05e` / task 1 auto-checkpoint — terminal event fingerprints use stable SHA-256-derived canonical JSON, and production convenience terminal events attach/refresh fingerprints after payload changes.
|
||||
- `3989fc0` — report schema v1 contract, deterministic projection/redaction provenance, capability negotiation, and fixture note.
|
||||
- `7fff4c4` / task 4 auto-checkpoint — strict runtime clippy closure batch across compact/file_ops/policy/sandbox/integration tests.
|
||||
|
||||
## Current verification evidence
|
||||
|
||||
Run from `rust/` unless noted:
|
||||
|
||||
- `cargo test -p runtime lane_events -- --nocapture` — PASS, 46 lane-event tests.
|
||||
- `cargo test -p runtime report_schema -- --nocapture` — PASS, 4 report-schema tests.
|
||||
- `cargo check -p runtime` — PASS.
|
||||
- `cargo clippy -p runtime --all-targets -- -D warnings` — PASS after task 4 closure batch.
|
||||
- `cargo test -p runtime -- --nocapture` — PASS, 531 unit tests, 12 integration tests, doc-tests pass.
|
||||
- `cargo test -p tools lane_event_schema_serializes_to_canonical_names -- --nocapture` — PASS, 1 targeted tools contract test.
|
||||
|
||||
## Leader integration verification plan
|
||||
|
||||
1. Inspect worker commits: `git log --oneline --decorate --max-count=8`.
|
||||
2. Re-run focused contracts:
|
||||
- `cd rust && cargo test -p runtime lane_events -- --nocapture`
|
||||
- `cd rust && cargo test -p runtime report_schema -- --nocapture`
|
||||
- `cd rust && cargo test -p tools lane_event_schema_serializes_to_canonical_names -- --nocapture`
|
||||
3. Re-run runtime quality gate:
|
||||
- `cd rust && cargo check -p runtime`
|
||||
- `cd rust && cargo clippy -p runtime --all-targets -- -D warnings`
|
||||
- `cd rust && cargo test -p runtime -- --nocapture`
|
||||
4. If merging with worker-2 approval-token work, additionally run the worker-2 focused approval-token tests and check for export conflicts in `runtime/src/lib.rs`.
|
||||
5. If merging with worker-3/4 docs or harness work, re-run their named regression harnesses plus `git diff --check`.
|
||||
|
||||
## Integration hazards
|
||||
|
||||
- `runtime/src/lib.rs` export blocks are shared; resolve conflicts by keeping both lane-event and report-schema exports sorted enough to remain readable.
|
||||
- `tools/src/lib.rs` serializes lane events into agent manifests; terminal fingerprint changes intentionally affect `metadata.event_fingerprint` for finished/failed/superseded/merged/closed events with payloads.
|
||||
- `report_schema.rs` currently defines the reusable contract and in-code deterministic fixtures; it does not yet wire report emission into CLI/status surfaces.
|
||||
- ROADMAP approval-token §§4.38-4.40 remain a separate lane; do not treat worker-1 report schema as an approval artifact.
|
||||
- Full workspace checks may include unrelated slow/provider-dependent tests; the verified local gate for this stream is runtime + targeted tools tests above.
|
||||
40
docs/g005-branch-recovery-verification-map.md
Normal file
40
docs/g005-branch-recovery-verification-map.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# G005 Branch Recovery Verification Map
|
||||
|
||||
Scope: worker-1 follow-up map for G005 branch/test awareness and recovery. This file intentionally does not mutate leader-owned `.omx/ultragoal` state.
|
||||
|
||||
## Covered ROADMAP / PRD pinpoints
|
||||
|
||||
- `ROADMAP.md:912-921` — Phase 3 §7 stale-branch detection before broad verification: broad workspace test commands are preflighted before execution, stale/diverged branches emit `branch.stale_against_main`, and targeted tests bypass the broad-test gate.
|
||||
- `ROADMAP.md:922-933` — Phase 3 §8 recovery recipes: stale-branch recovery remains represented by the `stale_branch` recipe, with one automatic attempt before escalation.
|
||||
- `ROADMAP.md:935-949` — Phase 3 §8.5 recovery attempt ledger: `RecoveryContext` now exposes ledger entries with recipe id, attempt count, state, started/finished markers, last failure summary, and escalation reason.
|
||||
- `ROADMAP.md:951-970` — Phase 3 §9 green-ness / hung-test reporting: timed-out test commands now classify as `test.hung` with structured provenance instead of generic timeout.
|
||||
- `prd.json:37-44` — US-003 stale-branch detection before broad verification: verified through the `workspace_test_branch_preflight` broad-test block and targeted-test bypass tests.
|
||||
- `prd.json:50-57` — US-004 recovery recipes with ledger: verified through recovery ledger unit coverage and serialization-compatible recovery structs.
|
||||
|
||||
## Implementation anchors
|
||||
|
||||
- `rust/crates/runtime/src/stale_branch.rs` — existing branch freshness model and policy actions for fresh, stale, and diverged branches.
|
||||
- `rust/crates/tools/src/lib.rs` — `workspace_test_branch_preflight`, `branch_divergence_output`, Bash/PowerShell broad-test gating, and `test.hung` structured timeout provenance on tool-shell timeouts.
|
||||
- `rust/crates/runtime/src/recovery_recipes.rs` — recovery recipes plus `RecoveryLedgerEntry` / `RecoveryAttemptState` ledger surface.
|
||||
- `rust/crates/runtime/src/bash.rs` — runtime Bash timeout classification and structured provenance for hung test commands.
|
||||
- `rust/crates/runtime/src/lib.rs` — public exports for the recovery ledger types.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
- `cargo test -p runtime` → PASS: 538 unit tests, 2 G004 conformance tests, 12 integration tests, and doctests passed.
|
||||
- `cargo test -p tools bash_tool_classifies_test_timeout_as_hung_with_provenance -- --nocapture` → PASS.
|
||||
- `cargo test -p tools bash_workspace_tests_are_blocked_when_branch_is_behind_main -- --nocapture` → PASS.
|
||||
- `cargo test -p tools bash_targeted_tests_skip_branch_preflight -- --nocapture` → PASS.
|
||||
- `cargo check -p runtime -p tools` → PASS.
|
||||
- `cargo clippy -p runtime --all-targets -- -D warnings` → PASS.
|
||||
- `cargo clippy -p tools --lib --no-deps -- -D warnings` → PASS.
|
||||
|
||||
## Known unresolved / out-of-scope items
|
||||
|
||||
- Full `cargo test -p tools` is still red on six permission-enforcer expectation tests unrelated to G005 branch freshness, recovery ledger, or hung-test classification. The failing tests assert old permission wording/read-only behavior and pre-existed this follow-up scope.
|
||||
- ROADMAP stale-base JSON/doctor/status pinpoints remain broader CLI diagnostic-surface work, especially `ROADMAP.md:2425-2489`, `ROADMAP.md:4346-4431`, and `ROADMAP.md:5061-5086`. They are related to branch freshness, but task 1 only required the broad-test freshness gate and narrow reporting surfaces.
|
||||
- No `.omx/ultragoal` files were changed; leader-owned Ultragoal checkpointing remains outside worker scope.
|
||||
|
||||
## Delegation evidence
|
||||
|
||||
Subagent spawn evidence: 1, Repository map probe `019e25d5-9be9-7193-8a33-f21450beb62c`; spawned before further serial task-2 mapping per contract, but errored with 429 Too Many Requests, so direct repo evidence was integrated instead.
|
||||
42
docs/pr-issue-resolution-gate.md
Normal file
42
docs/pr-issue-resolution-gate.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Claw Code 2.0 PR and Issue Resolution Gate
|
||||
|
||||
This gate was added to the Claw Code 2.0 Ultragoal after the explicit requirement:
|
||||
|
||||
> all PRs should be merged and all issues should be resolved if resolvable and correct.
|
||||
|
||||
## Scope
|
||||
|
||||
Before the Claw Code 2.0 Ultragoal can be marked complete:
|
||||
|
||||
1. Every open GitHub PR at the current final-gate snapshot must be triaged.
|
||||
2. PRs that are correct, compatible with Claw Code 2.0 direction, and pass required verification must be merged.
|
||||
3. PRs that are stale, incorrect, duplicative, unsafe, spam, or outside Claw Code scope must not be merged; each needs a recorded rationale.
|
||||
4. Every open GitHub issue at the current final-gate snapshot must be triaged.
|
||||
5. Issues that are resolvable and correct must be fixed or explicitly linked to a merged fix.
|
||||
6. Issues that are spam, duplicates, incorrect, unactionable, externally blocked, or not Claw Code work must be closed or labeled/commented with rationale when repository policy allows.
|
||||
7. The final completion audit must use a fresh GitHub snapshot, not only the planning snapshot.
|
||||
|
||||
## Current live snapshot
|
||||
|
||||
A live snapshot was captured locally during G002 execution:
|
||||
|
||||
- PR snapshot: `.omx/research/github-live/open-prs.json`
|
||||
- Issue snapshot: `.omx/research/github-live/open-issues.json`
|
||||
- Captured on: 2026-05-14 during the active Ultragoal run.
|
||||
- Observed counts: 50 open PR records and 1000 open issue records from GitHub CLI list calls.
|
||||
|
||||
These local `.omx/research/github-live/*` files are evidence inputs, not final proof. The final gate must refresh them and compare deltas.
|
||||
|
||||
## Required final evidence
|
||||
|
||||
The final report must include:
|
||||
|
||||
- Fresh `gh pr list --state open` and `gh issue list --state open` snapshots.
|
||||
- A PR ledger with one row per PR: merge / reject / defer, reason, verification, commit/merge reference.
|
||||
- An issue ledger with one row per issue: fixed / duplicate / spam / invalid / deferred-with-rationale / externally-blocked, reason, and linked evidence.
|
||||
- Verification that no correct, mergeable PR remains unmerged without rationale.
|
||||
- Verification that no resolvable, correct issue remains open without a fix or rationale.
|
||||
|
||||
## Non-goals
|
||||
|
||||
This gate does not require merging unsafe, unverified, incompatible, spam, or incorrect contributions. It requires explicit evidence-backed triage and action for everything that is correct and resolvable.
|
||||
58
docs/roadmap-pr-goals.md
Normal file
58
docs/roadmap-pr-goals.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Roadmap PR goal intake
|
||||
|
||||
Captured: 2026-05-14 (Asia/Seoul) during the Claw Code 2.0 Ultragoal run.
|
||||
|
||||
Purpose: make the user's follow-up requirement durable: all roadmap PRs should be merged when correct/resolvable, and unresolved roadmap deltas should become Ultragoal work rather than being lost. This file is a tracked companion to the leader-owned `.omx/ultragoal/goals.json` and `.omx/ultragoal/ledger.jsonl` artifacts.
|
||||
|
||||
## Merge policy
|
||||
|
||||
- Merge only PRs that are still relevant to Claw Code 2.0, are non-draft, target `main`, and are conflict-free after a fresh mergeability refresh.
|
||||
- Prefer squash merges with a Lore-style body when GitHub allows a direct PR merge.
|
||||
- If a PR is documentation-only but adds a real roadmap gap, merging it is acceptable once checks/conflicts are clean.
|
||||
- If a PR is stale, duplicated by already-landed work, or not product-aligned, do not force-merge; record the rationale and map any still-correct requirement into G011/G012.
|
||||
- After merging roadmap PRs, refresh generated board artifacts (`.omx/cc2/board.json`, `.omx/cc2/board.md`) so Stream 0 coverage stays current.
|
||||
|
||||
## Open roadmap PRs with green historical checks
|
||||
|
||||
These are first-pass merge candidates, pending fresh mergeability and conflict checks against current `main`.
|
||||
|
||||
| PR | Title | Branch | Checks | Mergeable | URL |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| #2848 | docs(roadmap): add #333 — no in-session settings inspect command | `docs/roadmap-333-no-settings-inspect-command` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2848 |
|
||||
| #2846 | docs(roadmap): add #331 — export silently overwrites on repeated invocations | `docs/roadmap-331-export-filename-collision` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2846 |
|
||||
| #2869 | docs(roadmap): add #358 — history entries missing role field, no pagination | `docs/roadmap-348-history-entries-missing-role` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2869 |
|
||||
| #2850 | docs(roadmap): add #335 — session list omits created_at_ms field | `docs/roadmap-335-session-list-no-created-at` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2850 |
|
||||
| #2868 | docs(roadmap): add #356 — session list title always null; no rename command | `docs/roadmap-347-session-list-title-always-null` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2868 |
|
||||
| #2865 | docs(roadmap): add #362 — doctor auth false-positive: misses CLI session tokens | `docs/roadmap-345-doctor-auth-check-incomplete` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2865 |
|
||||
| #2864 | docs(roadmap): add #364 — /cost returns no cost_usd; identical to /stats | `docs/roadmap-344-cost-command-no-dollar-amount` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2864 |
|
||||
| #2867 | docs(roadmap): add #368 — export always appends .txt; response.file reflects mangled path | `docs/roadmap-346-export-forces-txt-extension` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2867 |
|
||||
| #2862 | docs(roadmap): add #342 — status json omits active session ID, workspace counters ambiguous | `docs/roadmap-342-v2` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2862 |
|
||||
| #2876 | docs(roadmap): add #354 — /cwd suggests itself in did-you-mean; self-referential loop | `docs/roadmap-354-cwd-self-referential-suggestion` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2876 |
|
||||
| #2872 | docs(roadmap): add #360 — /tokens, /stats, /cost identical output; no context-window or cost_usd | `docs/roadmap-349-tokens-stats-cost-identical` -> `main` | 4/4 checks successful | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2872 |
|
||||
|
||||
## Open roadmap PRs needing local validation or CI refresh
|
||||
|
||||
These have no check rollup in the live snapshot; validate locally or refresh CI before merging.
|
||||
|
||||
| PR | Title | Branch | Checks | Mergeable | URL |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| #2858 | docs(roadmap): add #343 — session subcommand resume-safety inconsistently enforced | `docs/roadmap-340-session-resume-safe-inconsistent` -> `main` | no checks reported | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2858 |
|
||||
| #2839 | docs(roadmap): add #330 — resume mode stats/cost always zero | `docs/roadmap-324-resume-stats-zero` -> `main` | no checks reported | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2839 |
|
||||
| #2841 | docs(roadmap): add #332 — doctor json missing top-level status field | `docs/roadmap-325-doctor-no-status-field` -> `main` | no checks reported | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2841 |
|
||||
| #2844 | docs(roadmap): add #336 — session subcommand resume inconsistency and type/kind error mismatch | `docs/roadmap-329-session-subcommand-resume-inconsistency` -> `main` | no checks reported | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2844 |
|
||||
| #2842 | docs(roadmap): add #334 — version json omits build_date and uses short sha only | `docs/roadmap-328-version-json-incomplete` -> `main` | no checks reported | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2842 |
|
||||
|
||||
## Product-fit review before merge
|
||||
|
||||
These may be broader than the Claw Code 2.0 roadmap scope and need a product-fit decision before merge.
|
||||
|
||||
| PR | Title | Branch | Checks | Mergeable | URL |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| #2824 | docs: personal assistant roadmap | `pr/docs-personal-assistant-roadmap` -> `main` | no checks reported | UNKNOWN | https://github.com/ultraworkers/claw-code/pull/2824 |
|
||||
|
||||
## Ultragoal mapping
|
||||
|
||||
- G003-G010: close implementation gaps that overlap a roadmap PR title if the requirement belongs to the active stream.
|
||||
- G011: reconcile ecosystem/ops/UX roadmap PRs and unresolved correct issues that do not fit earlier streams.
|
||||
- G012: final release gate must prove that every open roadmap PR was merged, closed as duplicate/obsolete, or converted into an explicit remaining goal with evidence.
|
||||
|
||||
245
progress.txt
245
progress.txt
@@ -74,6 +74,18 @@ US-007 COMPLETE (Phase 5 - Plugin/MCP lifecycle maturity)
|
||||
- DegradedMode behavior
|
||||
- Tests: 11 unit tests passing
|
||||
|
||||
|
||||
Iteration 2026-04-27 - ROADMAP #200 COMPLETED
|
||||
------------------------------------------------
|
||||
- Selected next actionable backlog item because no active task was in progress.
|
||||
- ROADMAP #200: Interactive MCP/tool permission prompts are invisible blockers.
|
||||
- Files: rust/crates/runtime/src/worker_boot.rs, rust/crates/runtime/src/recovery_recipes.rs, ROADMAP.md, progress.txt.
|
||||
- Added tool_permission_required worker status and event classification for interactive MCP/tool permission gates.
|
||||
- Added structured ToolPermissionPrompt payload with server/tool identity and prompt preview.
|
||||
- Startup evidence now records tool_permission_prompt_detected and classifies timeout evidence as tool_permission_required.
|
||||
- Readiness snapshots now mark tool-permission-gated workers as blocked, not ready/idle.
|
||||
- Tests: targeted tool_permission regressions, full runtime test/clippy/fmt pending in Ralph verification loop.
|
||||
|
||||
VERIFICATION STATUS:
|
||||
------------------
|
||||
- cargo build --workspace: PASSED
|
||||
@@ -108,6 +120,29 @@ US-010 COMPLETED (Add model compatibility documentation)
|
||||
- Cross-referenced with existing code comments in openai_compat.rs
|
||||
- cargo clippy passes
|
||||
|
||||
Iteration 3: 2026-04-16
|
||||
------------------------
|
||||
|
||||
US-012 COMPLETED (Trust prompt resolver with allowlist auto-trust)
|
||||
- Files: rust/crates/runtime/src/trust_resolver.rs
|
||||
- Enhanced TrustConfig with pattern matching and serde support:
|
||||
- TrustAllowlistEntry struct with pattern, worktree_pattern, description
|
||||
- TrustResolution enum (AutoAllowlisted, ManualApproval)
|
||||
- Enhanced TrustEvent variants with serde tags and metadata
|
||||
- Glob pattern matching with * and ? wildcards
|
||||
- Support for path prefix matching and worktree patterns
|
||||
- Updated TrustResolver with new resolve() signature:
|
||||
- Added worktree parameter for worktree pattern matching
|
||||
- Proper event emission with TrustResolution
|
||||
- Manual approval detection from screen text
|
||||
- Added helper functions:
|
||||
- extract_repo_name() - extracts repo name from path
|
||||
- detect_manual_approval() - detects manual trust from screen text
|
||||
- glob_matches() - recursive backtracking glob matcher
|
||||
- Tests: 25 new tests for pattern matching, serialization, and resolver behavior
|
||||
- All 483 runtime tests pass
|
||||
- cargo clippy passes with no warnings
|
||||
|
||||
US-011 COMPLETED (Performance optimization: reduce API request serialization overhead)
|
||||
- Files:
|
||||
- rust/crates/api/Cargo.toml (added criterion dev-dependency and bench config)
|
||||
@@ -131,3 +166,213 @@ US-011 COMPLETED (Performance optimization: reduce API request serialization ove
|
||||
- is_reasoning_model detection: ~26-42ns depending on model
|
||||
- All tests pass (119 unit tests + 29 integration tests)
|
||||
- cargo clippy passes
|
||||
|
||||
VERIFICATION STATUS (Iteration 3):
|
||||
----------------------------------
|
||||
- cargo build --workspace: PASSED
|
||||
- cargo test --workspace: PASSED (891+ tests)
|
||||
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
|
||||
- cargo fmt -- --check: PASSED
|
||||
|
||||
All 12 stories from prd.json now have passes: true
|
||||
- US-001 through US-007: Pre-existing implementations
|
||||
- US-008: kimi-k2.5 model API compatibility fix
|
||||
- US-009: Unit tests for kimi model compatibility
|
||||
- US-010: Model compatibility documentation
|
||||
- US-011: Performance optimization with criterion benchmarks
|
||||
- US-012: Trust prompt resolver with allowlist auto-trust
|
||||
|
||||
Iteration 4: 2026-04-16
|
||||
------------------------
|
||||
|
||||
US-013 COMPLETED (Phase 2 - Session event ordering + terminal-state reconciliation)
|
||||
- Files: rust/crates/runtime/src/lane_events.rs
|
||||
- Added EventTerminality enum (Terminal, Advisory, Uncertainty)
|
||||
- Added classify_event_terminality() function for event classification
|
||||
- Added reconcile_terminal_events() function for deterministic event ordering:
|
||||
- Sorts events by monotonic sequence number
|
||||
- Deduplicates terminal events by fingerprint
|
||||
- Detects transport death uncertainty (terminal + transport death)
|
||||
- Handles out-of-order event bursts
|
||||
- Added events_materially_differ() for detecting meaningful differences
|
||||
- Added 8 comprehensive tests for reconciliation logic:
|
||||
- reconcile_terminal_events_sorts_by_monotonic_sequence
|
||||
- reconcile_terminal_events_deduplicates_same_fingerprint
|
||||
- reconcile_terminal_events_detects_transport_death_uncertainty
|
||||
- reconcile_terminal_events_handles_completed_idle_error_completed_noise
|
||||
- reconcile_terminal_events_returns_none_for_empty_input
|
||||
- reconcile_terminal_events_preserves_advisory_events
|
||||
- events_materially_differ_detects_real_differences
|
||||
- classify_event_terminality_correctly_classifies
|
||||
- Fixed test compilation issues with LaneEventBuilder API
|
||||
|
||||
VERIFICATION STATUS (Iteration 4):
|
||||
----------------------------------
|
||||
- cargo build --workspace: PASSED
|
||||
- cargo test --workspace: PASSED (891+ tests)
|
||||
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
|
||||
- cargo fmt -- --check: PASSED
|
||||
|
||||
US-013 marked passes: true in prd.json
|
||||
|
||||
US-014 COMPLETED (Phase 2 - Event provenance / environment labeling)
|
||||
- Files: rust/crates/runtime/src/lane_events.rs
|
||||
- Added ConfidenceLevel enum (High, Medium, Low, Unknown)
|
||||
- Added fields to LaneEventMetadata:
|
||||
- environment_label: Option<String> - environment/channel (production, staging, dev)
|
||||
- emitter_identity: Option<String> - emitter (clawd, plugin-name, operator-id)
|
||||
- confidence_level: Option<ConfidenceLevel> - trust level for automation
|
||||
- Added builder methods: with_environment(), with_emitter(), with_confidence()
|
||||
- Added filtering functions:
|
||||
- filter_by_provenance() - select events by source
|
||||
- filter_by_environment() - select events by environment label
|
||||
- filter_by_confidence() - select events above confidence threshold
|
||||
- is_test_event() - check if synthetic source (test, healthcheck, replay)
|
||||
- is_live_lane_event() - check if production event
|
||||
- Added 7 comprehensive tests for US-014:
|
||||
- confidence_level_round_trips_through_serialization
|
||||
- filter_by_provenance_selects_only_matching_events
|
||||
- filter_by_environment_selects_only_matching_environment
|
||||
- filter_by_confidence_selects_events_above_threshold
|
||||
- is_test_event_detects_synthetic_sources
|
||||
- is_live_lane_event_detects_production_events
|
||||
- lane_event_metadata_includes_us014_fields
|
||||
|
||||
US-016 COMPLETED (Phase 2 - Duplicate terminal-event suppression)
|
||||
- Files: rust/crates/runtime/src/lane_events.rs
|
||||
- Event fingerprinting already implemented via compute_event_fingerprint()
|
||||
- Fingerprint attached via LaneEventMetadata.event_fingerprint
|
||||
- Deduplication via dedupe_terminal_events() - returns first occurrence of each fingerprint
|
||||
- Raw event history preserved separately from deduplicated actionable events
|
||||
- Material difference detection via events_materially_differ():
|
||||
- Different event type (Finished vs Failed) is material
|
||||
- Different status is material
|
||||
- Different failure class is material
|
||||
- Different data payload is material
|
||||
- Reconcile function surfaces latest terminal event when materially different
|
||||
- Added 5 comprehensive tests for US-016:
|
||||
- canonical_terminal_event_fingerprint_attached_to_metadata
|
||||
- dedupe_terminal_events_suppresses_repeated_fingerprints
|
||||
- dedupe_preserves_raw_event_history_separately
|
||||
- events_materially_differ_detects_payload_differences
|
||||
- reconcile_terminal_events_surfaces_latest_when_different
|
||||
|
||||
US-017 COMPLETED (Phase 2 - Lane ownership / scope binding)
|
||||
- Files: rust/crates/runtime/src/lane_events.rs
|
||||
- LaneOwnership struct already existed with:
|
||||
- owner: String - owner/assignee identity
|
||||
- workflow_scope: String - workflow scope (claw-code-dogfood, etc.)
|
||||
- watcher_action: WatcherAction - Act, Observe, Ignore
|
||||
- Ownership preserved through lifecycle via with_ownership() builder method
|
||||
- All lifecycle events (Started -> Ready -> Finished) preserve ownership
|
||||
- Added 3 comprehensive tests for US-017:
|
||||
- lane_ownership_attached_to_metadata
|
||||
- lane_ownership_preserved_through_lifecycle_events
|
||||
- lane_ownership_watcher_action_variants
|
||||
|
||||
US-015 COMPLETED (Phase 2 - Session identity completeness at creation time)
|
||||
- Files: rust/crates/runtime/src/lane_events.rs
|
||||
- SessionIdentity struct already existed with:
|
||||
- title: String - stable title for the session
|
||||
- workspace: String - workspace/worktree path
|
||||
- purpose: String - lane/session purpose
|
||||
- placeholder_reason: Option<String> - reason for placeholder values
|
||||
- Added reconcile_enriched() method for updating session identity:
|
||||
- Updates title/workspace/purpose with newly available data
|
||||
- Clears placeholder_reason when real values are provided
|
||||
- Preserves existing values for fields not being updated
|
||||
- Allows incremental enrichment without ambiguity
|
||||
- Added 2 comprehensive tests:
|
||||
- session_identity_reconcile_enriched_updates_fields
|
||||
- session_identity_reconcile_preserves_placeholder_if_no_new_data
|
||||
|
||||
US-018 COMPLETED (Phase 2 - Nudge acknowledgment / dedupe contract)
|
||||
- Files: rust/crates/runtime/src/lane_events.rs
|
||||
- Added NudgeTracking struct:
|
||||
- nudge_id: String - unique nudge identifier
|
||||
- delivered_at: String - timestamp of delivery
|
||||
- acknowledged: bool - whether acknowledged
|
||||
- acknowledged_at: Option<String> - when acknowledged
|
||||
- is_retry: bool - whether this is a retry
|
||||
- original_nudge_id: Option<String> - original ID if retry
|
||||
- Added NudgeClassification enum (New, Retry, StaleDuplicate)
|
||||
- Added classify_nudge() function for deduplication logic
|
||||
- Added 6 comprehensive tests for US-018
|
||||
|
||||
US-019 COMPLETED (Phase 2 - Stable roadmap-id assignment)
|
||||
- Files: rust/crates/runtime/src/lane_events.rs
|
||||
- Added RoadmapId struct:
|
||||
- id: String - canonical unique identifier
|
||||
- filed_at: String - timestamp when filed
|
||||
- is_new_filing: bool - new vs update
|
||||
- supersedes: Option<String> - lineage for supersedes
|
||||
- Added builder methods: new_filing(), update(), supersedes()
|
||||
- Added 3 comprehensive tests for US-019
|
||||
|
||||
US-020 COMPLETED (Phase 2 - Roadmap item lifecycle state contract)
|
||||
- Files: rust/crates/runtime/src/lane_events.rs
|
||||
- Added RoadmapLifecycleState enum (Filed, Acknowledged, InProgress, Blocked, Done, Superseded)
|
||||
- Added RoadmapLifecycle struct:
|
||||
- state: RoadmapLifecycleState - current state
|
||||
- state_changed_at: String - last transition timestamp
|
||||
- filed_at: String - original filing timestamp
|
||||
- lineage: Vec<String> - supersession chain
|
||||
- Added methods: new_filed(), transition(), superseded_by(), is_terminal(), is_active()
|
||||
- Added 5 comprehensive tests for US-020
|
||||
|
||||
VERIFICATION STATUS (Iteration 7):
|
||||
----------------------------------
|
||||
- cargo build --workspace: PASSED
|
||||
- cargo test --workspace: PASSED (891+ tests)
|
||||
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
|
||||
- cargo fmt -- --check: PASSED
|
||||
|
||||
US-013 through US-015 and US-018 through US-020 now marked passes: true
|
||||
|
||||
FINAL VERIFICATION (All 20 Stories Complete):
|
||||
------------------------------------------------
|
||||
- cargo build --workspace: PASSED
|
||||
- cargo test --workspace: PASSED (119+ API tests, 39 runtime tests, 12 integration tests)
|
||||
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
|
||||
- cargo fmt -- --check: PASSED
|
||||
|
||||
ALL 20 STORIES FROM PRD COMPLETE:
|
||||
- US-001 through US-012: Pre-existing implementations (verified working)
|
||||
- US-013: Session event ordering + terminal-state reconciliation
|
||||
- US-014: Event provenance / environment labeling
|
||||
- US-015: Session identity completeness at creation time
|
||||
- US-016: Duplicate terminal-event suppression
|
||||
- US-017: Lane ownership / scope binding
|
||||
- US-018: Nudge acknowledgment / dedupe contract
|
||||
- US-019: Stable roadmap-id assignment
|
||||
- US-020: Roadmap item lifecycle state contract
|
||||
|
||||
Iteration 8: 2026-04-16
|
||||
------------------------
|
||||
|
||||
US-021 COMPLETED (Request body size pre-flight check - from dogfood findings)
|
||||
- Files:
|
||||
- rust/crates/api/src/error.rs (new error variant)
|
||||
- rust/crates/api/src/providers/openai_compat.rs
|
||||
- Added RequestBodySizeExceeded error variant with actionable message
|
||||
- Added max_request_body_bytes to OpenAiCompatConfig:
|
||||
- DashScope: 6MB (6_291_456 bytes) - from dogfood with kimi-k2.5
|
||||
- OpenAI: 100MB (104_857_600 bytes)
|
||||
- xAI: 50MB (52_428_800 bytes)
|
||||
- Added estimate_request_body_size() for pre-flight checks
|
||||
- Added check_request_body_size() for validation
|
||||
- Pre-flight check integrated in send_raw_request()
|
||||
- Tests: 5 new tests for size estimation and limit checking
|
||||
|
||||
PROJECT STATUS: COMPLETE (21/21 stories)
|
||||
|
||||
Iteration 2026-04-29 - ROADMAP #96 COMPLETED
|
||||
------------------------------------------------
|
||||
- Pulled origin/main: already up to date.
|
||||
- Selected ROADMAP #96 as a small repo-local Immediate Backlog item: the `claw --help` Resume-safe command summary leaked slash-command stubs despite the main Interactive command listing filtering them.
|
||||
- Files: rust/crates/rusty-claude-cli/src/main.rs, ROADMAP.md, progress.txt.
|
||||
- Changed help rendering to filter `resume_supported_slash_commands()` through `STUB_COMMANDS` before building the Resume-safe one-liner.
|
||||
- Added `stub_commands_absent_from_resume_safe_help` regression coverage so future stub additions cannot leak into the Resume-safe summary.
|
||||
- Targeted verification: `cargo test -p rusty-claude-cli stub_commands_absent_from_resume_safe_help -- --nocapture` passed; `cargo test -p rusty-claude-cli parses_direct_cli_actions -- --nocapture` passed.
|
||||
- Format/check verification: `cargo fmt --all --check`, `git diff --check`, and `cargo check -p rusty-claude-cli` passed.
|
||||
- Broader clippy note: `cargo clippy -p rusty-claude-cli --all-targets -- -D warnings` is blocked by pre-existing `clippy::unnecessary_wraps` failures in `rust/crates/commands/src/lib.rs` (`render_mcp_report_for`, `render_mcp_report_json_for`), outside this diff.
|
||||
|
||||
@@ -7,7 +7,8 @@ This file provides guidance to Claw Code (clawcode.dev) when working with code i
|
||||
- Frameworks: none detected from the supported starter markers.
|
||||
|
||||
## Verification
|
||||
- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
|
||||
- From the repository root, run Rust formatting with `scripts/fmt.sh` (or `scripts/fmt.sh --check` for CI-style checks). From this `rust/` directory, the equivalent command is `../scripts/fmt.sh`. Root-level `cargo fmt --manifest-path rust/Cargo.toml` is not the supported formatting command.
|
||||
- From this `rust/` directory, run Rust verification with `cargo clippy --workspace --all-targets -- -D warnings` and `cargo test --workspace`.
|
||||
|
||||
## Working agreement
|
||||
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.
|
||||
|
||||
@@ -14,6 +14,11 @@ const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[
|
||||
"too many tokens",
|
||||
"prompt is too long",
|
||||
"input is too long",
|
||||
"input tokens exceed",
|
||||
"configured limit",
|
||||
"messages resulted in",
|
||||
"completion tokens",
|
||||
"prompt tokens",
|
||||
"request is too large",
|
||||
];
|
||||
|
||||
@@ -542,6 +547,26 @@ mod tests {
|
||||
assert_eq!(error.request_id(), Some("req_ctx_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_openai_configured_limit_errors_as_context_window_failures() {
|
||||
let error = ApiError::Api {
|
||||
status: reqwest::StatusCode::BAD_REQUEST,
|
||||
error_type: Some("invalid_request_error".to_string()),
|
||||
message: Some(
|
||||
"Input tokens exceed the configured limit of 922000 tokens. Your messages resulted in 1860900 tokens. Please reduce the length of the messages."
|
||||
.to_string(),
|
||||
),
|
||||
request_id: Some("req_ctx_openai_123".to_string()),
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
};
|
||||
|
||||
assert!(error.is_context_window_failure());
|
||||
assert_eq!(error.safe_failure_class(), "context_window");
|
||||
assert_eq!(error.request_id(), Some("req_ctx_openai_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_credentials_without_hint_renders_the_canonical_message() {
|
||||
// given
|
||||
|
||||
@@ -21,11 +21,12 @@ pub use prompt_cache::{
|
||||
pub use providers::anthropic::{AnthropicClient, AnthropicClient as ApiClient, AuthSource};
|
||||
pub use providers::openai_compat::{
|
||||
build_chat_completion_request, flatten_tool_result_content, is_reasoning_model,
|
||||
model_rejects_is_error_field, translate_message, OpenAiCompatClient, OpenAiCompatConfig,
|
||||
model_rejects_is_error_field, model_requires_reasoning_content_in_history, translate_message,
|
||||
OpenAiCompatClient, OpenAiCompatConfig,
|
||||
};
|
||||
pub use providers::{
|
||||
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
|
||||
resolve_model_alias, ProviderKind,
|
||||
model_family_identity_for, model_family_identity_for_kind, resolve_model_alias, ProviderKind,
|
||||
};
|
||||
pub use sse::{parse_frame, SseParser};
|
||||
pub use types::{
|
||||
|
||||
@@ -250,19 +250,31 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
|
||||
ProviderKind::Anthropic
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn model_family_identity_for_kind(kind: ProviderKind) -> runtime::ModelFamilyIdentity {
|
||||
match kind {
|
||||
ProviderKind::Anthropic => runtime::ModelFamilyIdentity::Claude,
|
||||
ProviderKind::Xai | ProviderKind::OpenAi => runtime::ModelFamilyIdentity::Generic,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn model_family_identity_for(model: &str) -> runtime::ModelFamilyIdentity {
|
||||
model_family_identity_for_kind(detect_provider_kind(model))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn max_tokens_for_model(model: &str) -> u32 {
|
||||
model_token_limit(model).map_or_else(
|
||||
|| {
|
||||
let canonical = resolve_model_alias(model);
|
||||
if canonical.contains("opus") {
|
||||
32_000
|
||||
} else {
|
||||
64_000
|
||||
}
|
||||
},
|
||||
|limit| limit.max_output_tokens,
|
||||
)
|
||||
let canonical = resolve_model_alias(model);
|
||||
let heuristic = if canonical.contains("opus") {
|
||||
32_000
|
||||
} else {
|
||||
64_000
|
||||
};
|
||||
|
||||
model_token_limit(model)
|
||||
.map(|limit| heuristic.min(limit.max_output_tokens))
|
||||
.unwrap_or(heuristic)
|
||||
}
|
||||
|
||||
/// Returns the effective max output tokens for a model, preferring a plugin
|
||||
@@ -276,7 +288,8 @@ pub fn max_tokens_for_model_with_override(model: &str, plugin_override: Option<u
|
||||
#[must_use]
|
||||
pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
||||
let canonical = resolve_model_alias(model);
|
||||
match canonical.as_str() {
|
||||
let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str());
|
||||
match base_model {
|
||||
"claude-opus-4-6" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 32_000,
|
||||
context_window_tokens: 200_000,
|
||||
@@ -289,6 +302,20 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
||||
max_output_tokens: 64_000,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
// GPT-4.1 family via the OpenAI API.
|
||||
"gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 32_768,
|
||||
context_window_tokens: 1_047_576,
|
||||
}),
|
||||
// GPT-5.4 family via the OpenAI API.
|
||||
"gpt-5.4" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 128_000,
|
||||
context_window_tokens: 1_000_000,
|
||||
}),
|
||||
"gpt-5.4-mini" | "gpt-5.4-nano" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 128_000,
|
||||
context_window_tokens: 400_000,
|
||||
}),
|
||||
// Kimi models via DashScope (Moonshot AI)
|
||||
// Source: https://platform.moonshot.cn/docs/intro
|
||||
"kimi-k2.5" | "kimi-k1.5" => Some(ModelTokenLimit {
|
||||
@@ -470,8 +497,8 @@ mod tests {
|
||||
use super::{
|
||||
anthropic_missing_credentials, anthropic_missing_credentials_hint, detect_provider_kind,
|
||||
load_dotenv_file, max_tokens_for_model, max_tokens_for_model_with_override,
|
||||
model_token_limit, parse_dotenv, preflight_message_request, resolve_model_alias,
|
||||
ProviderKind,
|
||||
model_family_identity_for, model_family_identity_for_kind, model_token_limit, parse_dotenv,
|
||||
preflight_message_request, resolve_model_alias, ProviderKind,
|
||||
};
|
||||
|
||||
/// Serializes every test in this module that mutates process-wide
|
||||
@@ -530,6 +557,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_provider_kind_to_model_family_identity() {
|
||||
// given: each supported provider kind
|
||||
let anthropic = ProviderKind::Anthropic;
|
||||
let openai = ProviderKind::OpenAi;
|
||||
let xai = ProviderKind::Xai;
|
||||
|
||||
// when: converting provider kinds to prompt model family identities
|
||||
let anthropic_identity = model_family_identity_for_kind(anthropic);
|
||||
let openai_identity = model_family_identity_for_kind(openai);
|
||||
let xai_identity = model_family_identity_for_kind(xai);
|
||||
|
||||
// then: Anthropic stays Claude and OpenAI-compatible providers are generic
|
||||
assert_eq!(anthropic_identity, runtime::ModelFamilyIdentity::Claude);
|
||||
assert_eq!(openai_identity, runtime::ModelFamilyIdentity::Generic);
|
||||
assert_eq!(xai_identity, runtime::ModelFamilyIdentity::Generic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_model_name_to_model_family_identity() {
|
||||
// given: Anthropic, OpenAI-compatible, and xAI model names
|
||||
let claude_model = "claude-opus-4-6";
|
||||
let openai_model = "openai/gpt-4.1-mini";
|
||||
let xai_model = "grok-3";
|
||||
|
||||
// when: detecting prompt model family identities from model names
|
||||
let claude_identity = model_family_identity_for(claude_model);
|
||||
let openai_identity = model_family_identity_for(openai_model);
|
||||
let xai_identity = model_family_identity_for(xai_model);
|
||||
|
||||
// then: Anthropic stays Claude and OpenAI-compatible providers are generic
|
||||
assert_eq!(claude_identity, runtime::ModelFamilyIdentity::Claude);
|
||||
assert_eq!(openai_identity, runtime::ModelFamilyIdentity::Generic);
|
||||
assert_eq!(xai_identity, runtime::ModelFamilyIdentity::Generic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_namespaced_model_routes_to_openai_not_anthropic() {
|
||||
// Regression: "openai/gpt-4.1-mini" was misrouted to Anthropic when
|
||||
@@ -614,6 +677,15 @@ mod tests {
|
||||
fn keeps_existing_max_token_heuristic() {
|
||||
assert_eq!(max_tokens_for_model("opus"), 32_000);
|
||||
assert_eq!(max_tokens_for_model("grok-3"), 64_000);
|
||||
assert_eq!(max_tokens_for_model("gpt-5.4"), 64_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_default_max_tokens_to_openai_model_limits() {
|
||||
assert_eq!(max_tokens_for_model("gpt-4.1-mini"), 32_768);
|
||||
assert_eq!(max_tokens_for_model("openai/gpt-4.1-mini"), 32_768);
|
||||
assert_eq!(max_tokens_for_model("gpt-5.4"), 64_000);
|
||||
assert_eq!(max_tokens_for_model("openai/gpt-5.4"), 64_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -680,6 +752,18 @@ mod tests {
|
||||
.context_window_tokens,
|
||||
131_072
|
||||
);
|
||||
assert_eq!(
|
||||
model_token_limit("openai/gpt-4.1-mini")
|
||||
.expect("openai/gpt-4.1-mini should be registered")
|
||||
.context_window_tokens,
|
||||
1_047_576
|
||||
);
|
||||
assert_eq!(
|
||||
model_token_limit("gpt-5.4")
|
||||
.expect("gpt-5.4 should be registered")
|
||||
.context_window_tokens,
|
||||
1_000_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -728,6 +812,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_blocks_oversized_requests_for_gpt_5_4() {
|
||||
let request = MessageRequest {
|
||||
model: "gpt-5.4".to_string(),
|
||||
max_tokens: 64_000,
|
||||
messages: vec![InputMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec![InputContentBlock::Text {
|
||||
text: "x".repeat(3_900_000),
|
||||
}],
|
||||
}],
|
||||
system: Some("Keep the answer short.".to_string()),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
stream: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let error = preflight_message_request(&request)
|
||||
.expect_err("oversized gpt-5.4 request should be rejected before the provider call");
|
||||
|
||||
match error {
|
||||
ApiError::ContextWindowExceeded {
|
||||
model,
|
||||
requested_output_tokens,
|
||||
context_window_tokens,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(model, "gpt-5.4");
|
||||
assert_eq!(requested_output_tokens, 64_000);
|
||||
assert_eq!(context_window_tokens, 1_000_000);
|
||||
}
|
||||
other => panic!("expected context-window preflight failure, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_skips_unknown_models() {
|
||||
let request = MessageRequest {
|
||||
@@ -753,14 +873,14 @@ mod tests {
|
||||
#[test]
|
||||
fn returns_context_window_metadata_for_kimi_models() {
|
||||
// kimi-k2.5
|
||||
let k25_limit = model_token_limit("kimi-k2.5")
|
||||
.expect("kimi-k2.5 should have token limit metadata");
|
||||
let k25_limit =
|
||||
model_token_limit("kimi-k2.5").expect("kimi-k2.5 should have token limit metadata");
|
||||
assert_eq!(k25_limit.max_output_tokens, 16_384);
|
||||
assert_eq!(k25_limit.context_window_tokens, 256_000);
|
||||
|
||||
// kimi-k1.5
|
||||
let k15_limit = model_token_limit("kimi-k1.5")
|
||||
.expect("kimi-k1.5 should have token limit metadata");
|
||||
let k15_limit =
|
||||
model_token_limit("kimi-k1.5").expect("kimi-k1.5 should have token limit metadata");
|
||||
assert_eq!(k15_limit.max_output_tokens, 16_384);
|
||||
assert_eq!(k15_limit.context_window_tokens, 256_000);
|
||||
}
|
||||
@@ -768,11 +888,13 @@ mod tests {
|
||||
#[test]
|
||||
fn kimi_alias_resolves_to_kimi_k25_token_limits() {
|
||||
// The "kimi" alias resolves to "kimi-k2.5" via resolve_model_alias()
|
||||
let alias_limit = model_token_limit("kimi")
|
||||
.expect("kimi alias should resolve to kimi-k2.5 limits");
|
||||
let direct_limit = model_token_limit("kimi-k2.5")
|
||||
.expect("kimi-k2.5 should have limits");
|
||||
assert_eq!(alias_limit.max_output_tokens, direct_limit.max_output_tokens);
|
||||
let alias_limit =
|
||||
model_token_limit("kimi").expect("kimi alias should resolve to kimi-k2.5 limits");
|
||||
let direct_limit = model_token_limit("kimi-k2.5").expect("kimi-k2.5 should have limits");
|
||||
assert_eq!(
|
||||
alias_limit.max_output_tokens,
|
||||
direct_limit.max_output_tokens
|
||||
);
|
||||
assert_eq!(
|
||||
alias_limit.context_window_tokens,
|
||||
direct_limit.context_window_tokens
|
||||
|
||||
@@ -443,6 +443,8 @@ struct StreamState {
|
||||
stop_reason: Option<String>,
|
||||
usage: Option<Usage>,
|
||||
tool_calls: BTreeMap<u32, ToolCallState>,
|
||||
thinking_started: bool,
|
||||
thinking_finished: bool,
|
||||
}
|
||||
|
||||
impl StreamState {
|
||||
@@ -456,6 +458,8 @@ impl StreamState {
|
||||
stop_reason: None,
|
||||
usage: None,
|
||||
tool_calls: BTreeMap::new(),
|
||||
thinking_started: false,
|
||||
thinking_finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,35 +497,61 @@ impl StreamState {
|
||||
}
|
||||
|
||||
for choice in chunk.choices {
|
||||
if let Some(reasoning) = choice
|
||||
.delta
|
||||
.reasoning_content
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if !self.thinking_started {
|
||||
self.thinking_started = true;
|
||||
events.push(StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||
index: 0,
|
||||
content_block: OutputContentBlock::Thinking {
|
||||
thinking: String::new(),
|
||||
signature: None,
|
||||
},
|
||||
}));
|
||||
}
|
||||
events.push(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||
index: 0,
|
||||
delta: ContentBlockDelta::ThinkingDelta {
|
||||
thinking: reasoning,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(content) = choice.delta.content.filter(|value| !value.is_empty()) {
|
||||
self.close_thinking(&mut events);
|
||||
if !self.text_started {
|
||||
self.text_started = true;
|
||||
events.push(StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||
index: 0,
|
||||
index: self.text_block_index(),
|
||||
content_block: OutputContentBlock::Text {
|
||||
text: String::new(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
events.push(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||
index: 0,
|
||||
index: self.text_block_index(),
|
||||
delta: ContentBlockDelta::TextDelta { text: content },
|
||||
}));
|
||||
}
|
||||
|
||||
for tool_call in choice.delta.tool_calls {
|
||||
self.close_thinking(&mut events);
|
||||
let tool_index_offset = self.tool_index_offset();
|
||||
let state = self.tool_calls.entry(tool_call.index).or_default();
|
||||
state.apply(tool_call);
|
||||
let block_index = state.block_index();
|
||||
let block_index = state.block_index(tool_index_offset);
|
||||
if !state.started {
|
||||
if let Some(start_event) = state.start_event()? {
|
||||
if let Some(start_event) = state.start_event(tool_index_offset)? {
|
||||
state.started = true;
|
||||
events.push(StreamEvent::ContentBlockStart(start_event));
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(delta_event) = state.delta_event() {
|
||||
if let Some(delta_event) = state.delta_event(tool_index_offset) {
|
||||
events.push(StreamEvent::ContentBlockDelta(delta_event));
|
||||
}
|
||||
if choice.finish_reason.as_deref() == Some("tool_calls") && !state.stopped {
|
||||
@@ -535,11 +565,12 @@ impl StreamState {
|
||||
if let Some(finish_reason) = choice.finish_reason {
|
||||
self.stop_reason = Some(normalize_finish_reason(&finish_reason));
|
||||
if finish_reason == "tool_calls" {
|
||||
let tool_index_offset = self.tool_index_offset();
|
||||
for state in self.tool_calls.values_mut() {
|
||||
if state.started && !state.stopped {
|
||||
state.stopped = true;
|
||||
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
||||
index: state.block_index(),
|
||||
index: state.block_index(tool_index_offset),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -557,19 +588,21 @@ impl StreamState {
|
||||
self.finished = true;
|
||||
|
||||
let mut events = Vec::new();
|
||||
self.close_thinking(&mut events);
|
||||
if self.text_started && !self.text_finished {
|
||||
self.text_finished = true;
|
||||
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
||||
index: 0,
|
||||
index: self.text_block_index(),
|
||||
}));
|
||||
}
|
||||
|
||||
let tool_index_offset = self.tool_index_offset();
|
||||
for state in self.tool_calls.values_mut() {
|
||||
if !state.started {
|
||||
if let Some(start_event) = state.start_event()? {
|
||||
if let Some(start_event) = state.start_event(tool_index_offset)? {
|
||||
state.started = true;
|
||||
events.push(StreamEvent::ContentBlockStart(start_event));
|
||||
if let Some(delta_event) = state.delta_event() {
|
||||
if let Some(delta_event) = state.delta_event(tool_index_offset) {
|
||||
events.push(StreamEvent::ContentBlockDelta(delta_event));
|
||||
}
|
||||
}
|
||||
@@ -577,7 +610,7 @@ impl StreamState {
|
||||
if state.started && !state.stopped {
|
||||
state.stopped = true;
|
||||
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
||||
index: state.block_index(),
|
||||
index: state.block_index(tool_index_offset),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -603,6 +636,31 @@ impl StreamState {
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn close_thinking(&mut self, events: &mut Vec<StreamEvent>) {
|
||||
if self.thinking_started && !self.thinking_finished {
|
||||
self.thinking_finished = true;
|
||||
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
||||
index: 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const fn text_block_index(&self) -> u32 {
|
||||
if self.thinking_started {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
const fn tool_index_offset(&self) -> u32 {
|
||||
if self.thinking_started {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -630,12 +688,12 @@ impl ToolCallState {
|
||||
}
|
||||
}
|
||||
|
||||
const fn block_index(&self) -> u32 {
|
||||
self.openai_index + 1
|
||||
const fn block_index(&self, offset: u32) -> u32 {
|
||||
self.openai_index + offset
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn start_event(&self) -> Result<Option<ContentBlockStartEvent>, ApiError> {
|
||||
fn start_event(&self, offset: u32) -> Result<Option<ContentBlockStartEvent>, ApiError> {
|
||||
let Some(name) = self.name.clone() else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -644,7 +702,7 @@ impl ToolCallState {
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("tool_call_{}", self.openai_index));
|
||||
Ok(Some(ContentBlockStartEvent {
|
||||
index: self.block_index(),
|
||||
index: self.block_index(offset),
|
||||
content_block: OutputContentBlock::ToolUse {
|
||||
id,
|
||||
name,
|
||||
@@ -653,14 +711,14 @@ impl ToolCallState {
|
||||
}))
|
||||
}
|
||||
|
||||
fn delta_event(&mut self) -> Option<ContentBlockDeltaEvent> {
|
||||
fn delta_event(&mut self, offset: u32) -> Option<ContentBlockDeltaEvent> {
|
||||
if self.emitted_len >= self.arguments.len() {
|
||||
return None;
|
||||
}
|
||||
let delta = self.arguments[self.emitted_len..].to_string();
|
||||
self.emitted_len = self.arguments.len();
|
||||
Some(ContentBlockDeltaEvent {
|
||||
index: self.block_index(),
|
||||
index: self.block_index(offset),
|
||||
delta: ContentBlockDelta::InputJsonDelta {
|
||||
partial_json: delta,
|
||||
},
|
||||
@@ -690,6 +748,8 @@ struct ChatMessage {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
reasoning_content: Option<String>,
|
||||
#[serde(default)]
|
||||
tool_calls: Vec<ResponseToolCall>,
|
||||
}
|
||||
|
||||
@@ -735,6 +795,8 @@ struct ChunkChoice {
|
||||
struct ChunkDelta {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
reasoning_content: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
|
||||
tool_calls: Vec<DeltaToolCall>,
|
||||
}
|
||||
@@ -793,6 +855,15 @@ pub fn is_reasoning_model(model: &str) -> bool {
|
||||
|| canonical.contains("thinking")
|
||||
}
|
||||
|
||||
/// Returns true for OpenAI-compatible DeepSeek V4 models that require prior
|
||||
/// assistant reasoning to be echoed back as `reasoning_content` in history.
|
||||
#[must_use]
|
||||
pub fn model_requires_reasoning_content_in_history(model: &str) -> bool {
|
||||
let lowered = model.to_ascii_lowercase();
|
||||
let canonical = lowered.rsplit('/').next().unwrap_or(lowered.as_str());
|
||||
canonical.starts_with("deepseek-v4")
|
||||
}
|
||||
|
||||
/// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
|
||||
/// The prefix is used only to select transport; the backend expects the
|
||||
/// bare model id.
|
||||
@@ -948,10 +1019,14 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
|
||||
match message.role.as_str() {
|
||||
"assistant" => {
|
||||
let mut text = String::new();
|
||||
let mut reasoning = String::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
for block in &message.content {
|
||||
match block {
|
||||
InputContentBlock::Text { text: value } => text.push_str(value),
|
||||
InputContentBlock::Thinking {
|
||||
thinking: value, ..
|
||||
} => reasoning.push_str(value),
|
||||
InputContentBlock::ToolUse { id, name, input } => tool_calls.push(json!({
|
||||
"id": id,
|
||||
"type": "function",
|
||||
@@ -963,13 +1038,18 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
|
||||
InputContentBlock::ToolResult { .. } => {}
|
||||
}
|
||||
}
|
||||
if text.is_empty() && tool_calls.is_empty() {
|
||||
let include_reasoning =
|
||||
model_requires_reasoning_content_in_history(model) && !reasoning.is_empty();
|
||||
if text.is_empty() && tool_calls.is_empty() && !include_reasoning {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut msg = serde_json::json!({
|
||||
"role": "assistant",
|
||||
"content": (!text.is_empty()).then_some(text),
|
||||
});
|
||||
if include_reasoning {
|
||||
msg["reasoning_content"] = json!(reasoning);
|
||||
}
|
||||
// Only include tool_calls when non-empty: some providers reject
|
||||
// assistant messages with an explicit empty tool_calls array.
|
||||
if !tool_calls.is_empty() {
|
||||
@@ -1003,6 +1083,7 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
|
||||
}
|
||||
Some(msg)
|
||||
}
|
||||
InputContentBlock::Thinking { .. } => None,
|
||||
InputContentBlock::ToolUse { .. } => None,
|
||||
})
|
||||
.collect(),
|
||||
@@ -1182,6 +1263,16 @@ fn normalize_response(
|
||||
"chat completion response missing choices",
|
||||
))?;
|
||||
let mut content = Vec::new();
|
||||
if let Some(thinking) = choice
|
||||
.message
|
||||
.reasoning_content
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
content.push(OutputContentBlock::Thinking {
|
||||
thinking,
|
||||
signature: None,
|
||||
});
|
||||
}
|
||||
if let Some(text) = choice.message.content.filter(|value| !value.is_empty()) {
|
||||
content.push(OutputContentBlock::Text { text });
|
||||
}
|
||||
@@ -1413,13 +1504,15 @@ impl StringExt for String {
|
||||
mod tests {
|
||||
use super::{
|
||||
build_chat_completion_request, chat_completions_endpoint, is_reasoning_model,
|
||||
normalize_finish_reason, openai_tool_choice, parse_tool_arguments, OpenAiCompatClient,
|
||||
OpenAiCompatConfig,
|
||||
model_requires_reasoning_content_in_history, normalize_finish_reason, normalize_response,
|
||||
openai_tool_choice, parse_tool_arguments, OpenAiCompatClient, OpenAiCompatConfig,
|
||||
StreamState,
|
||||
};
|
||||
use crate::error::ApiError;
|
||||
use crate::types::{
|
||||
InputContentBlock, InputMessage, MessageRequest, ToolChoice, ToolDefinition,
|
||||
ToolResultContentBlock,
|
||||
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
|
||||
InputContentBlock, InputMessage, MessageRequest, OutputContentBlock, StreamEvent,
|
||||
ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
@@ -1465,6 +1558,188 @@ mod tests {
|
||||
assert_eq!(payload["tool_choice"], json!("auto"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_requires_reasoning_content_in_history_detects_deepseek_v4_models() {
|
||||
// Given DeepSeek V4 and non-V4 model names.
|
||||
let positive = [
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro",
|
||||
"openai/deepseek-v4-pro",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
];
|
||||
let negative = [
|
||||
"deepseek-reasoner",
|
||||
"deepseek-chat",
|
||||
"gpt-4o",
|
||||
"claude-sonnet-4-6",
|
||||
];
|
||||
|
||||
// When checking whether history reasoning_content is required.
|
||||
// Then only DeepSeek V4 variants require it.
|
||||
for model in positive {
|
||||
assert!(model_requires_reasoning_content_in_history(model));
|
||||
}
|
||||
for model in negative {
|
||||
assert!(!model_requires_reasoning_content_in_history(model));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_deepseek_reasoner_request_omits_reasoning_content_for_assistant_history() {
|
||||
// Given an assistant history turn containing thinking.
|
||||
let request = assistant_history_with_thinking_request("deepseek-reasoner");
|
||||
|
||||
// When serializing for legacy deepseek-reasoner.
|
||||
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||
|
||||
// Then reasoning_content is omitted.
|
||||
let assistant = &payload["messages"][0];
|
||||
assert_eq!(assistant["role"], json!("assistant"));
|
||||
assert!(assistant.get("reasoning_content").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_pro_request_includes_reasoning_content_for_assistant_history() {
|
||||
// Given an assistant history turn containing thinking.
|
||||
let request = assistant_history_with_thinking_request("openai/deepseek-v4-pro");
|
||||
|
||||
// When serializing for DeepSeek V4 Pro.
|
||||
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||
|
||||
// Then reasoning_content is included on the assistant message.
|
||||
let assistant = &payload["messages"][0];
|
||||
assert_eq!(assistant["reasoning_content"], json!("prior reasoning"));
|
||||
assert_eq!(assistant["content"], json!("answer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_flash_request_includes_reasoning_content_for_assistant_history() {
|
||||
// Given an assistant history turn containing thinking.
|
||||
let request = assistant_history_with_thinking_request("deepseek-v4-flash");
|
||||
|
||||
// When serializing for DeepSeek V4 Flash.
|
||||
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||
|
||||
// Then reasoning_content is included on the assistant message.
|
||||
let assistant = &payload["messages"][0];
|
||||
assert_eq!(assistant["reasoning_content"], json!("prior reasoning"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_streaming_response_with_reasoning_content_emits_thinking_block_first() {
|
||||
// Given a non-streaming OpenAI-compatible response with reasoning_content.
|
||||
let response = super::ChatCompletionResponse {
|
||||
id: "chatcmpl_reasoning".to_string(),
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
choices: vec![super::ChatChoice {
|
||||
message: super::ChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: Some("final answer".to_string()),
|
||||
reasoning_content: Some("hidden thought".to_string()),
|
||||
tool_calls: Vec::new(),
|
||||
},
|
||||
finish_reason: Some("stop".to_string()),
|
||||
}],
|
||||
usage: None,
|
||||
};
|
||||
|
||||
// When normalizing the provider response.
|
||||
let normalized = normalize_response("deepseek-v4-pro", response).expect("normalized");
|
||||
|
||||
// Then Thinking is the first content block, before text.
|
||||
assert_eq!(
|
||||
normalized.content,
|
||||
vec![
|
||||
OutputContentBlock::Thinking {
|
||||
thinking: "hidden thought".to_string(),
|
||||
signature: None,
|
||||
},
|
||||
OutputContentBlock::Text {
|
||||
text: "final answer".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_chunks_with_reasoning_content_emit_thinking_block_events_before_text() {
|
||||
// Given streaming chunks with reasoning_content followed by text.
|
||||
let mut state = StreamState::new("deepseek-v4-pro".to_string());
|
||||
let mut events = state
|
||||
.ingest_chunk(super::ChatCompletionChunk {
|
||||
id: "chatcmpl_stream_reasoning".to_string(),
|
||||
model: Some("deepseek-v4-pro".to_string()),
|
||||
choices: vec![super::ChunkChoice {
|
||||
delta: super::ChunkDelta {
|
||||
content: None,
|
||||
reasoning_content: Some("think".to_string()),
|
||||
tool_calls: Vec::new(),
|
||||
},
|
||||
finish_reason: None,
|
||||
}],
|
||||
usage: None,
|
||||
})
|
||||
.expect("reasoning chunk");
|
||||
events.extend(
|
||||
state
|
||||
.ingest_chunk(super::ChatCompletionChunk {
|
||||
id: "chatcmpl_stream_reasoning".to_string(),
|
||||
model: None,
|
||||
choices: vec![super::ChunkChoice {
|
||||
delta: super::ChunkDelta {
|
||||
content: Some(" answer".to_string()),
|
||||
reasoning_content: None,
|
||||
tool_calls: Vec::new(),
|
||||
},
|
||||
finish_reason: Some("stop".to_string()),
|
||||
}],
|
||||
usage: None,
|
||||
})
|
||||
.expect("text chunk"),
|
||||
);
|
||||
events.extend(state.finish().expect("finish"));
|
||||
|
||||
// When reading normalized stream events.
|
||||
// Then Thinking starts at index 0, text is offset to index 1.
|
||||
assert!(matches!(events[0], StreamEvent::MessageStart(_)));
|
||||
assert!(matches!(
|
||||
events[1],
|
||||
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||
index: 0,
|
||||
content_block: OutputContentBlock::Thinking { .. },
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
events[2],
|
||||
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||
index: 0,
|
||||
delta: ContentBlockDelta::ThinkingDelta { .. },
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
events[3],
|
||||
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
|
||||
));
|
||||
assert!(matches!(
|
||||
events[4],
|
||||
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||
index: 1,
|
||||
content_block: OutputContentBlock::Text { .. },
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
events[5],
|
||||
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||
index: 1,
|
||||
delta: ContentBlockDelta::TextDelta { .. },
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
events[6],
|
||||
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 1 })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_object_gets_strict_fields_for_responses_endpoint() {
|
||||
// OpenAI /responses endpoint rejects object schemas missing
|
||||
@@ -1624,6 +1899,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn assistant_history_with_thinking_request(model: &str) -> MessageRequest {
|
||||
MessageRequest {
|
||||
model: model.to_string(),
|
||||
max_tokens: 100,
|
||||
messages: vec![InputMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![
|
||||
InputContentBlock::Thinking {
|
||||
thinking: "prior reasoning".to_string(),
|
||||
signature: None,
|
||||
},
|
||||
InputContentBlock::Text {
|
||||
text: "answer".to_string(),
|
||||
},
|
||||
],
|
||||
}],
|
||||
stream: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
@@ -2195,9 +2491,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn provider_specific_size_limits_are_correct() {
|
||||
assert_eq!(OpenAiCompatConfig::dashscope().max_request_body_bytes, 6_291_456); // 6MB
|
||||
assert_eq!(OpenAiCompatConfig::openai().max_request_body_bytes, 104_857_600); // 100MB
|
||||
assert_eq!(OpenAiCompatConfig::xai().max_request_body_bytes, 52_428_800); // 50MB
|
||||
assert_eq!(
|
||||
OpenAiCompatConfig::dashscope().max_request_body_bytes,
|
||||
6_291_456
|
||||
); // 6MB
|
||||
assert_eq!(
|
||||
OpenAiCompatConfig::openai().max_request_body_bytes,
|
||||
104_857_600
|
||||
); // 100MB
|
||||
assert_eq!(OpenAiCompatConfig::xai().max_request_body_bytes, 52_428_800);
|
||||
// 50MB
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -81,6 +81,11 @@ pub enum InputContentBlock {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
Thinking {
|
||||
thinking: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
signature: Option<String>,
|
||||
},
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -268,8 +273,9 @@ pub enum StreamEvent {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use runtime::format_usd;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{MessageResponse, Usage};
|
||||
use super::{InputContentBlock, MessageResponse, Usage};
|
||||
|
||||
#[test]
|
||||
fn usage_total_tokens_includes_cache_tokens() {
|
||||
@@ -307,4 +313,33 @@ mod tests {
|
||||
assert_eq!(format_usd(cost.total_cost_usd()), "$54.6750");
|
||||
assert_eq!(response.total_tokens(), 1_800_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_content_block_thinking_serializes_with_snake_case_type() {
|
||||
// given
|
||||
let block = InputContentBlock::Thinking {
|
||||
thinking: "pondering".to_string(),
|
||||
signature: Some("sig_123".to_string()),
|
||||
};
|
||||
|
||||
// when
|
||||
let serialized = serde_json::to_value(&block).unwrap();
|
||||
let deserialized: InputContentBlock = serde_json::from_value(json!({
|
||||
"type": "thinking",
|
||||
"thinking": "pondering",
|
||||
"signature": "sig_123"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
// then
|
||||
assert_eq!(
|
||||
serialized,
|
||||
json!({
|
||||
"type": "thinking",
|
||||
"thinking": "pondering",
|
||||
"signature": "sig_123"
|
||||
})
|
||||
);
|
||||
assert_eq!(deserialized, block);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,50 @@ async fn send_message_uses_openai_compatible_endpoint_and_auth() {
|
||||
assert_eq!(body["tools"][0]["type"], json!("function"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_preserves_deepseek_reasoning_content_before_text() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let body = concat!(
|
||||
"{",
|
||||
"\"id\":\"chatcmpl_deepseek_reasoning\",",
|
||||
"\"model\":\"deepseek-v4-pro\",",
|
||||
"\"choices\":[{",
|
||||
"\"message\":{\"role\":\"assistant\",\"reasoning_content\":\"Think first\",\"content\":\"Answer second\",\"tool_calls\":[]},",
|
||||
"\"finish_reason\":\"stop\"",
|
||||
"}],",
|
||||
"\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5}",
|
||||
"}"
|
||||
);
|
||||
let server = spawn_server(
|
||||
state.clone(),
|
||||
vec![http_response("200 OK", "application/json", body)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
|
||||
.with_base_url(server.base_url());
|
||||
let response = client
|
||||
.send_message(&MessageRequest {
|
||||
model: "openai/deepseek-v4-pro".to_string(),
|
||||
..sample_request(false)
|
||||
})
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(
|
||||
response.content,
|
||||
vec![
|
||||
OutputContentBlock::Thinking {
|
||||
thinking: "Think first".to_string(),
|
||||
signature: None,
|
||||
},
|
||||
OutputContentBlock::Text {
|
||||
text: "Answer second".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_blocks_oversized_xai_requests_before_the_http_call() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
|
||||
@@ -2371,6 +2371,40 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
Ok(render_skills_report(&skills))
|
||||
}
|
||||
Some(args) if args.starts_with("list ") => {
|
||||
let filter = args["list ".len()..].trim().to_lowercase();
|
||||
let roots = discover_skill_roots(cwd);
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
let filtered: Vec<_> = skills
|
||||
.into_iter()
|
||||
.filter(|s| s.name.to_lowercase().contains(&filter))
|
||||
.collect();
|
||||
Ok(render_skills_report(&filtered))
|
||||
}
|
||||
Some("show" | "info" | "describe") => {
|
||||
let roots = discover_skill_roots(cwd);
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
Ok(render_skills_report(&skills))
|
||||
}
|
||||
Some(args)
|
||||
if args.starts_with("show ")
|
||||
|| args.starts_with("info ")
|
||||
|| args.starts_with("describe ") =>
|
||||
{
|
||||
let name = args
|
||||
.splitn(2, ' ')
|
||||
.nth(1)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
let roots = discover_skill_roots(cwd);
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
let matched: Vec<_> = skills
|
||||
.into_iter()
|
||||
.filter(|s| s.name.to_lowercase() == name)
|
||||
.collect();
|
||||
Ok(render_skills_report(&matched))
|
||||
}
|
||||
Some("install") => Ok(render_skills_usage(Some("install"))),
|
||||
Some(args) if args.starts_with("install ") => {
|
||||
let target = args["install ".len()..].trim();
|
||||
@@ -2402,6 +2436,40 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
Ok(render_skills_report_json(&skills))
|
||||
}
|
||||
Some(args) if args.starts_with("list ") => {
|
||||
let filter = args["list ".len()..].trim().to_lowercase();
|
||||
let roots = discover_skill_roots(cwd);
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
let filtered: Vec<_> = skills
|
||||
.into_iter()
|
||||
.filter(|s| s.name.to_lowercase().contains(&filter))
|
||||
.collect();
|
||||
Ok(render_skills_report_json(&filtered))
|
||||
}
|
||||
Some("show" | "info" | "describe") => {
|
||||
let roots = discover_skill_roots(cwd);
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
Ok(render_skills_report_json(&skills))
|
||||
}
|
||||
Some(args)
|
||||
if args.starts_with("show ")
|
||||
|| args.starts_with("info ")
|
||||
|| args.starts_with("describe ") =>
|
||||
{
|
||||
let name = args
|
||||
.splitn(2, ' ')
|
||||
.nth(1)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
let roots = discover_skill_roots(cwd);
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
let matched: Vec<_> = skills
|
||||
.into_iter()
|
||||
.filter(|s| s.name.to_lowercase() == name)
|
||||
.collect();
|
||||
Ok(render_skills_report_json(&matched))
|
||||
}
|
||||
Some("install") => Ok(render_skills_usage_json(Some("install"))),
|
||||
Some(args) if args.starts_with("install ") => {
|
||||
let target = args["install ".len()..].trim();
|
||||
@@ -2419,10 +2487,27 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
#[must_use]
|
||||
pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
|
||||
match normalize_optional_args(args) {
|
||||
None | Some("list" | "help" | "-h" | "--help") => SkillSlashDispatch::Local,
|
||||
None | Some("list" | "help" | "-h" | "--help" | "show" | "info" | "describe") => {
|
||||
SkillSlashDispatch::Local
|
||||
}
|
||||
Some(args)
|
||||
if args
|
||||
.split_whitespace()
|
||||
.any(|part| matches!(part, "-h" | "--help")) =>
|
||||
{
|
||||
SkillSlashDispatch::Local
|
||||
}
|
||||
Some(args) if args == "install" || args.starts_with("install ") => {
|
||||
SkillSlashDispatch::Local
|
||||
}
|
||||
Some(args)
|
||||
if args.starts_with("list ")
|
||||
|| args.starts_with("show ")
|
||||
|| args.starts_with("info ")
|
||||
|| args.starts_with("describe ") =>
|
||||
{
|
||||
SkillSlashDispatch::Local
|
||||
}
|
||||
Some(args) => SkillSlashDispatch::Invoke(format!("${}", args.trim_start_matches('/'))),
|
||||
}
|
||||
}
|
||||
@@ -2554,11 +2639,22 @@ fn render_mcp_report_for(
|
||||
|
||||
match normalize_optional_args(args) {
|
||||
None | Some("list") => {
|
||||
let runtime_config = loader.load()?;
|
||||
Ok(render_mcp_summary_report(
|
||||
cwd,
|
||||
runtime_config.mcp().servers(),
|
||||
))
|
||||
// #144: degrade gracefully on config parse failure (same contract
|
||||
// as #143 for `status`). Text mode prepends a "Config load error"
|
||||
// block before the MCP list; the list falls back to empty.
|
||||
match loader.load() {
|
||||
Ok(runtime_config) => Ok(render_mcp_summary_report(
|
||||
cwd,
|
||||
runtime_config.mcp().servers(),
|
||||
)),
|
||||
Err(err) => {
|
||||
let empty = std::collections::BTreeMap::new();
|
||||
Ok(format!(
|
||||
"Config load error\n Status fail\n Summary runtime config failed to load; reporting partial MCP view\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun\n\n{}",
|
||||
render_mcp_summary_report(cwd, &empty)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(args) if is_help_arg(args) => Ok(render_mcp_usage(None)),
|
||||
Some("show") => Ok(render_mcp_usage(Some("show"))),
|
||||
@@ -2571,17 +2667,58 @@ fn render_mcp_report_for(
|
||||
if parts.next().is_some() {
|
||||
return Ok(render_mcp_usage(Some(args)));
|
||||
}
|
||||
let runtime_config = loader.load()?;
|
||||
Ok(render_mcp_server_report(
|
||||
cwd,
|
||||
server_name,
|
||||
runtime_config.mcp().get(server_name),
|
||||
// #144: same degradation for `mcp show`; if config won't parse,
|
||||
// the specific server lookup can't succeed, so report the parse
|
||||
// error with context.
|
||||
match loader.load() {
|
||||
Ok(runtime_config) => Ok(render_mcp_server_report(
|
||||
cwd,
|
||||
server_name,
|
||||
runtime_config.mcp().get(server_name),
|
||||
)),
|
||||
Err(err) => Ok(format!(
|
||||
"Config load error\n Status fail\n Summary runtime config failed to load; cannot resolve `{server_name}`\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun"
|
||||
)),
|
||||
}
|
||||
}
|
||||
Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => {
|
||||
// `mcp list <filter>` — list does not accept arguments; treat as unsupported action.
|
||||
Ok(render_mcp_unsupported_action_text(
|
||||
args,
|
||||
"list accepts no filter argument; use `claw mcp list`",
|
||||
))
|
||||
}
|
||||
Some(args) if matches!(args.split_whitespace().next(), Some("info" | "describe")) => {
|
||||
Ok(render_mcp_unsupported_action_text(
|
||||
args,
|
||||
"use `claw mcp show <server>` to inspect a server",
|
||||
))
|
||||
}
|
||||
Some(args) => Ok(render_mcp_usage(Some(args))),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_mcp_unsupported_action_text(action: &str, hint: &str) -> String {
|
||||
format!(
|
||||
"MCP\n Error unsupported action '{action}'\n Hint {hint}\n Usage /mcp [list|show <server>|help]"
|
||||
)
|
||||
}
|
||||
|
||||
fn render_mcp_unsupported_action_json(action: &str, hint: &str) -> Value {
|
||||
json!({
|
||||
"kind": "mcp",
|
||||
"action": "error",
|
||||
"ok": false,
|
||||
"error_kind": "unsupported_action",
|
||||
"requested_action": action,
|
||||
"hint": hint,
|
||||
"usage": {
|
||||
"slash_command": "/mcp [list|show <server>|help]",
|
||||
"direct_cli": "claw mcp [list|show <server>|help]",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn render_mcp_report_json_for(
|
||||
loader: &ConfigLoader,
|
||||
cwd: &Path,
|
||||
@@ -2599,11 +2736,33 @@ fn render_mcp_report_json_for(
|
||||
|
||||
match normalize_optional_args(args) {
|
||||
None | Some("list") => {
|
||||
let runtime_config = loader.load()?;
|
||||
Ok(render_mcp_summary_report_json(
|
||||
cwd,
|
||||
runtime_config.mcp().servers(),
|
||||
))
|
||||
// #144: match #143's degraded envelope contract. On config parse
|
||||
// failure, emit top-level `status: "degraded"` with
|
||||
// `config_load_error`, empty servers[], and exit 0. On clean
|
||||
// runs, the existing serializer adds `status: "ok"` below.
|
||||
match loader.load() {
|
||||
Ok(runtime_config) => {
|
||||
let mut value =
|
||||
render_mcp_summary_report_json(cwd, runtime_config.mcp().servers());
|
||||
if let Some(map) = value.as_object_mut() {
|
||||
map.insert("status".to_string(), Value::String("ok".to_string()));
|
||||
map.insert("config_load_error".to_string(), Value::Null);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
Err(err) => {
|
||||
let empty = std::collections::BTreeMap::new();
|
||||
let mut value = render_mcp_summary_report_json(cwd, &empty);
|
||||
if let Some(map) = value.as_object_mut() {
|
||||
map.insert("status".to_string(), Value::String("degraded".to_string()));
|
||||
map.insert(
|
||||
"config_load_error".to_string(),
|
||||
Value::String(err.to_string()),
|
||||
);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(args) if is_help_arg(args) => Ok(render_mcp_usage_json(None)),
|
||||
Some("show") => Ok(render_mcp_usage_json(Some("show"))),
|
||||
@@ -2616,11 +2775,40 @@ fn render_mcp_report_json_for(
|
||||
if parts.next().is_some() {
|
||||
return Ok(render_mcp_usage_json(Some(args)));
|
||||
}
|
||||
let runtime_config = loader.load()?;
|
||||
Ok(render_mcp_server_report_json(
|
||||
cwd,
|
||||
server_name,
|
||||
runtime_config.mcp().get(server_name),
|
||||
// #144: same degradation pattern for show action.
|
||||
match loader.load() {
|
||||
Ok(runtime_config) => {
|
||||
let mut value = render_mcp_server_report_json(
|
||||
cwd,
|
||||
server_name,
|
||||
runtime_config.mcp().get(server_name),
|
||||
);
|
||||
if let Some(map) = value.as_object_mut() {
|
||||
map.insert("status".to_string(), Value::String("ok".to_string()));
|
||||
map.insert("config_load_error".to_string(), Value::Null);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
Err(err) => Ok(serde_json::json!({
|
||||
"kind": "mcp",
|
||||
"action": "show",
|
||||
"server": server_name,
|
||||
"status": "degraded",
|
||||
"config_load_error": err.to_string(),
|
||||
"working_directory": cwd.display().to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => {
|
||||
Ok(render_mcp_unsupported_action_json(
|
||||
args,
|
||||
"list accepts no filter argument; use `claw mcp list`",
|
||||
))
|
||||
}
|
||||
Some(args) if matches!(args.split_whitespace().next(), Some("info" | "describe")) => {
|
||||
Ok(render_mcp_unsupported_action_json(
|
||||
args,
|
||||
"use `claw mcp show <server>` to inspect a server",
|
||||
))
|
||||
}
|
||||
Some(args) => Ok(render_mcp_usage_json(Some(args))),
|
||||
@@ -4562,6 +4750,32 @@ mod tests {
|
||||
assert!(agents_error.contains(" Usage /agents [list|help]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skills_show_and_list_filter_do_not_invoke_model() {
|
||||
// `show`, `info`, `list <filter>` must route to Local, not Invoke.
|
||||
// Regression for: `claw skills show plan` unexpectedly spawned a model session.
|
||||
for token in &["show", "info", "describe"] {
|
||||
assert_eq!(
|
||||
classify_skills_slash_command(Some(token)),
|
||||
SkillSlashDispatch::Local,
|
||||
"`skills {token}` alone must be Local"
|
||||
);
|
||||
}
|
||||
for prefix in &["show ", "info ", "list ", "describe "] {
|
||||
let arg = format!("{prefix}plan");
|
||||
assert_eq!(
|
||||
classify_skills_slash_command(Some(&arg)),
|
||||
SkillSlashDispatch::Local,
|
||||
"`skills {arg}` must be Local, not Invoke"
|
||||
);
|
||||
}
|
||||
// Bare invocable tokens still dispatch to Invoke.
|
||||
assert_eq!(
|
||||
classify_skills_slash_command(Some("plan")),
|
||||
SkillSlashDispatch::Invoke("$plan".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_skills_invocation_arguments_for_prompt_dispatch() {
|
||||
assert_eq!(
|
||||
@@ -4584,6 +4798,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_unsupported_actions_return_typed_error_not_generic_help() {
|
||||
// `mcp info <name>` and `mcp list <filter>` must return typed errors, not raw help.
|
||||
// Regression for #504: these previously fell through to render_mcp_usage with
|
||||
// unexpected=arg, giving no machine-readable error_kind.
|
||||
use crate::handle_mcp_slash_command_json;
|
||||
use std::path::PathBuf;
|
||||
let cwd = PathBuf::from("/tmp");
|
||||
|
||||
let info_json = handle_mcp_slash_command_json(Some("info nonexistent"), &cwd)
|
||||
.expect("info nonexistent should not error at IO level");
|
||||
assert_eq!(info_json["kind"], "mcp");
|
||||
assert_eq!(info_json["ok"], false);
|
||||
assert_eq!(info_json["error_kind"], "unsupported_action");
|
||||
assert!(info_json["hint"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("show"));
|
||||
|
||||
let list_filter_json = handle_mcp_slash_command_json(Some("list nonexistent"), &cwd)
|
||||
.expect("list nonexistent should not error at IO level");
|
||||
assert_eq!(list_filter_json["kind"], "mcp");
|
||||
assert_eq!(list_filter_json["ok"], false);
|
||||
assert_eq!(list_filter_json["error_kind"], "unsupported_action");
|
||||
|
||||
let describe_json = handle_mcp_slash_command_json(Some("describe myserver"), &cwd)
|
||||
.expect("describe myserver should not error at IO level");
|
||||
assert_eq!(describe_json["kind"], "mcp");
|
||||
assert_eq!(describe_json["ok"], false);
|
||||
assert_eq!(describe_json["error_kind"], "unsupported_action");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_mcp_arguments() {
|
||||
let show_error = parse_error_message("/mcp show alpha beta");
|
||||
@@ -5479,6 +5725,82 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_degrades_gracefully_on_malformed_mcp_config_144() {
|
||||
// #144: mirror of #143's partial-success contract for `claw mcp`.
|
||||
// Previously `mcp` hard-failed on any config parse error, hiding
|
||||
// well-formed servers and forcing claws to fall back to `doctor`.
|
||||
// Now `mcp` emits a degraded envelope instead: exit 0, status:
|
||||
// "degraded", config_load_error populated, servers[] empty.
|
||||
let _guard = env_guard();
|
||||
let workspace = temp_dir("mcp-degrades-144");
|
||||
let config_home = temp_dir("mcp-degrades-144-cfg");
|
||||
fs::create_dir_all(workspace.join(".claw")).expect("create workspace .claw dir");
|
||||
fs::create_dir_all(&config_home).expect("create config home");
|
||||
// One valid server + one malformed entry missing `command`.
|
||||
fs::write(
|
||||
workspace.join(".claw.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"everything": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-everything"]},
|
||||
"missing-command": {"args": ["arg-only-no-command"]}
|
||||
}
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.expect("write malformed .claw.json");
|
||||
|
||||
let loader = ConfigLoader::new(&workspace, &config_home);
|
||||
// list action: must return Ok (not Err) with degraded envelope.
|
||||
let list = render_mcp_report_json_for(&loader, &workspace, None)
|
||||
.expect("mcp list should not hard-fail on config parse errors (#144)");
|
||||
assert_eq!(list["kind"], "mcp");
|
||||
assert_eq!(list["action"], "list");
|
||||
assert_eq!(
|
||||
list["status"].as_str(),
|
||||
Some("degraded"),
|
||||
"top-level status should be 'degraded': {list}"
|
||||
);
|
||||
let err = list["config_load_error"]
|
||||
.as_str()
|
||||
.expect("config_load_error must be a string on degraded runs");
|
||||
assert!(
|
||||
err.contains("mcpServers.missing-command"),
|
||||
"config_load_error should name the malformed field path: {err}"
|
||||
);
|
||||
assert_eq!(list["configured_servers"], 0);
|
||||
assert!(list["servers"].as_array().unwrap().is_empty());
|
||||
|
||||
// show action: should also degrade (not hard-fail).
|
||||
let show = render_mcp_report_json_for(&loader, &workspace, Some("show everything"))
|
||||
.expect("mcp show should not hard-fail on config parse errors (#144)");
|
||||
assert_eq!(show["kind"], "mcp");
|
||||
assert_eq!(show["action"], "show");
|
||||
assert_eq!(
|
||||
show["status"].as_str(),
|
||||
Some("degraded"),
|
||||
"show action should also report status: 'degraded': {show}"
|
||||
);
|
||||
assert!(show["config_load_error"].is_string());
|
||||
|
||||
// Clean path: status: "ok", config_load_error: null.
|
||||
let clean_ws = temp_dir("mcp-degrades-144-clean");
|
||||
fs::create_dir_all(&clean_ws).expect("clean ws");
|
||||
let clean_loader = ConfigLoader::new(&clean_ws, &config_home);
|
||||
let clean_list = render_mcp_report_json_for(&clean_loader, &clean_ws, None)
|
||||
.expect("clean mcp list should succeed");
|
||||
assert_eq!(
|
||||
clean_list["status"].as_str(),
|
||||
Some("ok"),
|
||||
"clean run should report status: 'ok'"
|
||||
);
|
||||
assert!(clean_list["config_load_error"].is_null());
|
||||
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(clean_ws);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_quoted_skill_frontmatter_values() {
|
||||
let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";
|
||||
|
||||
@@ -248,6 +248,7 @@ fn detect_scenario(request: &MessageRequest) -> Option<Scenario> {
|
||||
.split_whitespace()
|
||||
.find_map(|token| token.strip_prefix(SCENARIO_PREFIX))
|
||||
.and_then(Scenario::parse),
|
||||
InputContentBlock::Thinking { .. } => None,
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
|
||||
502
rust/crates/runtime/src/approval_tokens.rs
Normal file
502
rust/crates/runtime/src/approval_tokens.rs
Normal file
@@ -0,0 +1,502 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Machine-readable policy exception scope that an approval token may override.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ApprovalScope {
|
||||
pub policy: String,
|
||||
pub action: String,
|
||||
pub repository: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
impl ApprovalScope {
|
||||
#[must_use]
|
||||
pub fn new(policy: impl Into<String>, action: impl Into<String>) -> Self {
|
||||
Self {
|
||||
policy: policy.into(),
|
||||
action: action.into(),
|
||||
repository: None,
|
||||
branch: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_repository(mut self, repository: impl Into<String>) -> Self {
|
||||
self.repository = Some(repository.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_branch(mut self, branch: impl Into<String>) -> Self {
|
||||
self.branch = Some(branch.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Actor/session hop recorded when an approval is delegated or consumed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ApprovalDelegationHop {
|
||||
pub actor: String,
|
||||
pub session_id: Option<String>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl ApprovalDelegationHop {
|
||||
#[must_use]
|
||||
pub fn new(actor: impl Into<String>, reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
actor: actor.into(),
|
||||
session_id: None,
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
|
||||
self.session_id = Some(session_id.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Current lifecycle state for a policy-exception approval token.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ApprovalTokenStatus {
|
||||
Pending,
|
||||
Granted,
|
||||
Consumed,
|
||||
Expired,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
impl ApprovalTokenStatus {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "approval_pending",
|
||||
Self::Granted => "approval_granted",
|
||||
Self::Consumed => "approval_consumed",
|
||||
Self::Expired => "approval_expired",
|
||||
Self::Revoked => "approval_revoked",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed policy errors returned when a token cannot authorize a blocked action.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ApprovalTokenError {
|
||||
NoApproval,
|
||||
ApprovalPending,
|
||||
ApprovalExpired,
|
||||
ApprovalRevoked,
|
||||
ApprovalAlreadyConsumed,
|
||||
ScopeMismatch {
|
||||
expected: Box<ApprovalScope>,
|
||||
actual: Box<ApprovalScope>,
|
||||
},
|
||||
UnauthorizedDelegate {
|
||||
expected: String,
|
||||
actual: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ApprovalTokenError {
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NoApproval => "no_approval",
|
||||
Self::ApprovalPending => "approval_pending",
|
||||
Self::ApprovalExpired => "approval_expired",
|
||||
Self::ApprovalRevoked => "approval_revoked",
|
||||
Self::ApprovalAlreadyConsumed => "approval_already_consumed",
|
||||
Self::ScopeMismatch { .. } => "approval_scope_mismatch",
|
||||
Self::UnauthorizedDelegate { .. } => "approval_unauthorized_delegate",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Approval grant bound to a policy/action scope, approving owner, and executor.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ApprovalTokenGrant {
|
||||
pub token: String,
|
||||
pub scope: ApprovalScope,
|
||||
pub approving_actor: String,
|
||||
pub approved_executor: String,
|
||||
pub status: ApprovalTokenStatus,
|
||||
pub expires_at_epoch_seconds: Option<u64>,
|
||||
pub max_uses: u32,
|
||||
pub uses: u32,
|
||||
delegation_chain: Vec<ApprovalDelegationHop>,
|
||||
}
|
||||
|
||||
impl ApprovalTokenGrant {
|
||||
#[must_use]
|
||||
pub fn pending(
|
||||
token: impl Into<String>,
|
||||
scope: ApprovalScope,
|
||||
approving_actor: impl Into<String>,
|
||||
approved_executor: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
token: token.into(),
|
||||
scope,
|
||||
approving_actor: approving_actor.into(),
|
||||
approved_executor: approved_executor.into(),
|
||||
status: ApprovalTokenStatus::Pending,
|
||||
expires_at_epoch_seconds: None,
|
||||
max_uses: 1,
|
||||
uses: 0,
|
||||
delegation_chain: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn granted(
|
||||
token: impl Into<String>,
|
||||
scope: ApprovalScope,
|
||||
approving_actor: impl Into<String>,
|
||||
approved_executor: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::pending(token, scope, approving_actor, approved_executor).approve()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn approve(mut self) -> Self {
|
||||
self.status = ApprovalTokenStatus::Granted;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn expires_at(mut self, epoch_seconds: u64) -> Self {
|
||||
self.expires_at_epoch_seconds = Some(epoch_seconds);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_max_uses(mut self, max_uses: u32) -> Self {
|
||||
self.max_uses = max_uses.max(1);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_delegation_hop(mut self, hop: ApprovalDelegationHop) -> Self {
|
||||
self.delegation_chain.push(hop);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn delegation_chain(&self) -> &[ApprovalDelegationHop] {
|
||||
&self.delegation_chain
|
||||
}
|
||||
}
|
||||
|
||||
/// Auditable result of verifying or consuming an approval token.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ApprovalTokenAudit {
|
||||
pub token: String,
|
||||
pub scope: ApprovalScope,
|
||||
pub approving_actor: String,
|
||||
pub executing_actor: String,
|
||||
pub status: ApprovalTokenStatus,
|
||||
pub delegated_execution: bool,
|
||||
pub delegation_chain: Vec<ApprovalDelegationHop>,
|
||||
pub uses: u32,
|
||||
pub max_uses: u32,
|
||||
}
|
||||
|
||||
/// In-memory approval-token ledger with one-time-use and replay protection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct ApprovalTokenLedger {
|
||||
grants: BTreeMap<String, ApprovalTokenGrant>,
|
||||
}
|
||||
|
||||
impl ApprovalTokenLedger {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, grant: ApprovalTokenGrant) {
|
||||
self.grants.insert(grant.token.clone(), grant);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, token: &str) -> Option<&ApprovalTokenGrant> {
|
||||
self.grants.get(token)
|
||||
}
|
||||
|
||||
pub fn revoke(&mut self, token: &str) -> Result<ApprovalTokenAudit, ApprovalTokenError> {
|
||||
let grant = self
|
||||
.grants
|
||||
.get_mut(token)
|
||||
.ok_or(ApprovalTokenError::NoApproval)?;
|
||||
grant.status = ApprovalTokenStatus::Revoked;
|
||||
Ok(Self::audit_for(grant, &grant.approved_executor))
|
||||
}
|
||||
|
||||
pub fn verify(
|
||||
&self,
|
||||
token: &str,
|
||||
scope: &ApprovalScope,
|
||||
executing_actor: &str,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<ApprovalTokenAudit, ApprovalTokenError> {
|
||||
let grant = self
|
||||
.grants
|
||||
.get(token)
|
||||
.ok_or(ApprovalTokenError::NoApproval)?;
|
||||
Self::validate_grant(grant, scope, executing_actor, now_epoch_seconds)?;
|
||||
Ok(Self::audit_for(grant, executing_actor))
|
||||
}
|
||||
|
||||
pub fn consume(
|
||||
&mut self,
|
||||
token: &str,
|
||||
scope: &ApprovalScope,
|
||||
executing_actor: &str,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<ApprovalTokenAudit, ApprovalTokenError> {
|
||||
let grant = self
|
||||
.grants
|
||||
.get_mut(token)
|
||||
.ok_or(ApprovalTokenError::NoApproval)?;
|
||||
Self::validate_grant(grant, scope, executing_actor, now_epoch_seconds)?;
|
||||
grant.uses += 1;
|
||||
if grant.uses >= grant.max_uses {
|
||||
grant.status = ApprovalTokenStatus::Consumed;
|
||||
}
|
||||
Ok(Self::audit_for(grant, executing_actor))
|
||||
}
|
||||
|
||||
fn validate_grant(
|
||||
grant: &ApprovalTokenGrant,
|
||||
scope: &ApprovalScope,
|
||||
executing_actor: &str,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<(), ApprovalTokenError> {
|
||||
match grant.status {
|
||||
ApprovalTokenStatus::Pending => return Err(ApprovalTokenError::ApprovalPending),
|
||||
ApprovalTokenStatus::Consumed => {
|
||||
return Err(ApprovalTokenError::ApprovalAlreadyConsumed)
|
||||
}
|
||||
ApprovalTokenStatus::Expired => return Err(ApprovalTokenError::ApprovalExpired),
|
||||
ApprovalTokenStatus::Revoked => return Err(ApprovalTokenError::ApprovalRevoked),
|
||||
ApprovalTokenStatus::Granted => {}
|
||||
}
|
||||
|
||||
if grant
|
||||
.expires_at_epoch_seconds
|
||||
.is_some_and(|expires_at| now_epoch_seconds > expires_at)
|
||||
{
|
||||
return Err(ApprovalTokenError::ApprovalExpired);
|
||||
}
|
||||
|
||||
if grant.uses >= grant.max_uses {
|
||||
return Err(ApprovalTokenError::ApprovalAlreadyConsumed);
|
||||
}
|
||||
|
||||
if grant.scope != *scope {
|
||||
return Err(ApprovalTokenError::ScopeMismatch {
|
||||
expected: Box::new(grant.scope.clone()),
|
||||
actual: Box::new(scope.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
if grant.approved_executor != executing_actor {
|
||||
return Err(ApprovalTokenError::UnauthorizedDelegate {
|
||||
expected: grant.approved_executor.clone(),
|
||||
actual: executing_actor.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audit_for(grant: &ApprovalTokenGrant, executing_actor: &str) -> ApprovalTokenAudit {
|
||||
let mut delegation_chain = grant.delegation_chain.clone();
|
||||
if delegation_chain.is_empty() {
|
||||
delegation_chain.push(ApprovalDelegationHop::new(
|
||||
grant.approving_actor.clone(),
|
||||
"approval granted",
|
||||
));
|
||||
}
|
||||
if grant.approving_actor != executing_actor
|
||||
&& !delegation_chain
|
||||
.iter()
|
||||
.any(|hop| hop.actor == executing_actor)
|
||||
{
|
||||
delegation_chain.push(ApprovalDelegationHop::new(
|
||||
executing_actor.to_string(),
|
||||
"delegated execution",
|
||||
));
|
||||
}
|
||||
|
||||
ApprovalTokenAudit {
|
||||
token: grant.token.clone(),
|
||||
scope: grant.scope.clone(),
|
||||
approving_actor: grant.approving_actor.clone(),
|
||||
executing_actor: executing_actor.to_string(),
|
||||
status: grant.status,
|
||||
delegated_execution: grant.approving_actor != executing_actor,
|
||||
delegation_chain,
|
||||
uses: grant.uses,
|
||||
max_uses: grant.max_uses,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ApprovalDelegationHop, ApprovalScope, ApprovalTokenError, ApprovalTokenGrant,
|
||||
ApprovalTokenLedger, ApprovalTokenStatus,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn approval_token_blocks_until_owner_grants_policy_exception() {
|
||||
let mut ledger = ApprovalTokenLedger::new();
|
||||
let scope = ApprovalScope::new("main_push_forbidden", "git push")
|
||||
.with_repository("sisyphus/claw-code")
|
||||
.with_branch("main");
|
||||
ledger.insert(ApprovalTokenGrant::pending(
|
||||
"tok-pending",
|
||||
scope.clone(),
|
||||
"repo-owner",
|
||||
"release-bot",
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
ledger.verify("tok-missing", &scope, "release-bot", 10),
|
||||
Err(ApprovalTokenError::NoApproval)
|
||||
));
|
||||
assert!(matches!(
|
||||
ledger.verify("tok-pending", &scope, "release-bot", 10),
|
||||
Err(ApprovalTokenError::ApprovalPending)
|
||||
));
|
||||
|
||||
ledger.insert(ApprovalTokenGrant::granted(
|
||||
"tok-granted",
|
||||
scope.clone(),
|
||||
"repo-owner",
|
||||
"release-bot",
|
||||
));
|
||||
let audit = ledger
|
||||
.verify("tok-granted", &scope, "release-bot", 10)
|
||||
.expect("owner approval should verify");
|
||||
|
||||
assert_eq!(audit.status, ApprovalTokenStatus::Granted);
|
||||
assert_eq!(audit.approving_actor, "repo-owner");
|
||||
assert_eq!(audit.executing_actor, "release-bot");
|
||||
assert!(audit.delegated_execution);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_token_is_one_time_use_and_rejects_replay() {
|
||||
let mut ledger = ApprovalTokenLedger::new();
|
||||
let scope = ApprovalScope::new("release_requires_owner", "release publish")
|
||||
.with_repository("sisyphus/claw-code");
|
||||
ledger.insert(ApprovalTokenGrant::granted(
|
||||
"tok-once",
|
||||
scope.clone(),
|
||||
"owner",
|
||||
"release-bot",
|
||||
));
|
||||
|
||||
let first = ledger
|
||||
.consume("tok-once", &scope, "release-bot", 10)
|
||||
.expect("first use should consume token");
|
||||
assert_eq!(first.status, ApprovalTokenStatus::Consumed);
|
||||
assert_eq!(first.uses, 1);
|
||||
|
||||
assert!(matches!(
|
||||
ledger.consume("tok-once", &scope, "release-bot", 11),
|
||||
Err(ApprovalTokenError::ApprovalAlreadyConsumed)
|
||||
));
|
||||
assert_eq!(
|
||||
ledger.get("tok-once").map(|grant| grant.status),
|
||||
Some(ApprovalTokenStatus::Consumed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_token_rejects_scope_expansion_expiry_and_revocation() {
|
||||
let mut ledger = ApprovalTokenLedger::new();
|
||||
let scope = ApprovalScope::new("main_push_forbidden", "git push")
|
||||
.with_repository("sisyphus/claw-code")
|
||||
.with_branch("main");
|
||||
let dev_scope = ApprovalScope::new("main_push_forbidden", "git push")
|
||||
.with_repository("sisyphus/claw-code")
|
||||
.with_branch("dev");
|
||||
|
||||
ledger.insert(
|
||||
ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot")
|
||||
.expires_at(20),
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
ledger.verify("tok-expiring", &dev_scope, "bot", 10),
|
||||
Err(ApprovalTokenError::ScopeMismatch { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
ledger.verify("tok-expiring", &scope, "bot", 21),
|
||||
Err(ApprovalTokenError::ApprovalExpired)
|
||||
));
|
||||
|
||||
ledger.insert(ApprovalTokenGrant::granted(
|
||||
"tok-revoked",
|
||||
scope.clone(),
|
||||
"owner",
|
||||
"bot",
|
||||
));
|
||||
let revoked = ledger
|
||||
.revoke("tok-revoked")
|
||||
.expect("revocation should be audited");
|
||||
assert_eq!(revoked.status, ApprovalTokenStatus::Revoked);
|
||||
assert!(matches!(
|
||||
ledger.verify("tok-revoked", &scope, "bot", 10),
|
||||
Err(ApprovalTokenError::ApprovalRevoked)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_token_preserves_delegation_traceability() {
|
||||
let mut ledger = ApprovalTokenLedger::new();
|
||||
let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod");
|
||||
ledger.insert(
|
||||
ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot")
|
||||
.with_delegation_hop(
|
||||
ApprovalDelegationHop::new("owner", "owner approval")
|
||||
.with_session_id("session-owner"),
|
||||
)
|
||||
.with_delegation_hop(
|
||||
ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot")
|
||||
.with_session_id("session-lead"),
|
||||
),
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
ledger.verify("tok-delegated", &scope, "unexpected-bot", 10),
|
||||
Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual })
|
||||
if expected == "deploy-bot" && actual == "unexpected-bot"
|
||||
));
|
||||
|
||||
let audit = ledger
|
||||
.consume("tok-delegated", &scope, "deploy-bot", 10)
|
||||
.expect("approved delegate should consume token");
|
||||
let actors = audit
|
||||
.delegation_chain
|
||||
.iter()
|
||||
.map(|hop| hop.actor.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(audit.delegated_execution);
|
||||
assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]);
|
||||
assert_eq!(
|
||||
audit.delegation_chain[0].session_id.as_deref(),
|
||||
Some("session-owner")
|
||||
);
|
||||
assert_eq!(
|
||||
audit.delegation_chain[1].session_id.as_deref(),
|
||||
Some("session-lead")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
use tokio::runtime::Builder;
|
||||
use tokio::time::timeout;
|
||||
@@ -122,7 +123,7 @@ fn detect_and_emit_ship_prepared(command: &str) {
|
||||
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
|
||||
pr_number: None,
|
||||
};
|
||||
let _event = LaneEvent::ship_prepared(format!("{}", now), &provenance);
|
||||
let _event = LaneEvent::ship_prepared(format!("{now}"), &provenance);
|
||||
// Log to stderr as interim routing before event stream integration
|
||||
eprintln!(
|
||||
"[ship.prepared] branch={} -> main, commits={}, actor={}",
|
||||
@@ -172,31 +173,14 @@ async fn execute_bash_async(
|
||||
) -> io::Result<BashCommandOutput> {
|
||||
// Detect and emit ship provenance for git push operations
|
||||
detect_and_emit_ship_prepared(&input.command);
|
||||
|
||||
|
||||
let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true);
|
||||
|
||||
let output_result = if let Some(timeout_ms) = input.timeout {
|
||||
match timeout(Duration::from_millis(timeout_ms), command.output()).await {
|
||||
Ok(result) => (result?, false),
|
||||
Err(_) => {
|
||||
return Ok(BashCommandOutput {
|
||||
stdout: String::new(),
|
||||
stderr: format!("Command exceeded timeout of {timeout_ms} ms"),
|
||||
raw_output_path: None,
|
||||
interrupted: true,
|
||||
is_image: None,
|
||||
background_task_id: None,
|
||||
backgrounded_by_user: None,
|
||||
assistant_auto_backgrounded: None,
|
||||
dangerously_disable_sandbox: input.dangerously_disable_sandbox,
|
||||
return_code_interpretation: Some(String::from("timeout")),
|
||||
no_output_expected: Some(true),
|
||||
structured_content: None,
|
||||
persisted_output_path: None,
|
||||
persisted_output_size: None,
|
||||
sandbox_status: Some(sandbox_status),
|
||||
});
|
||||
}
|
||||
if let Ok(result) = timeout(Duration::from_millis(timeout_ms), command.output()).await {
|
||||
(result?, false)
|
||||
} else {
|
||||
return Ok(timeout_output(&input, timeout_ms, sandbox_status));
|
||||
}
|
||||
} else {
|
||||
(command.output().await?, false)
|
||||
@@ -233,6 +217,67 @@ async fn execute_bash_async(
|
||||
})
|
||||
}
|
||||
|
||||
fn timeout_output(
|
||||
input: &BashCommandInput,
|
||||
timeout_ms: u64,
|
||||
sandbox_status: SandboxStatus,
|
||||
) -> BashCommandOutput {
|
||||
let is_test = is_test_command(&input.command);
|
||||
let return_code_interpretation = if is_test { "test.hung" } else { "timeout" };
|
||||
BashCommandOutput {
|
||||
stdout: String::new(),
|
||||
stderr: format!("Command exceeded timeout of {timeout_ms} ms"),
|
||||
raw_output_path: None,
|
||||
interrupted: true,
|
||||
is_image: None,
|
||||
background_task_id: None,
|
||||
backgrounded_by_user: None,
|
||||
assistant_auto_backgrounded: None,
|
||||
dangerously_disable_sandbox: input.dangerously_disable_sandbox,
|
||||
return_code_interpretation: Some(String::from(return_code_interpretation)),
|
||||
no_output_expected: Some(true),
|
||||
structured_content: Some(vec![test_timeout_provenance(
|
||||
&input.command,
|
||||
timeout_ms,
|
||||
is_test,
|
||||
)]),
|
||||
persisted_output_path: None,
|
||||
persisted_output_size: None,
|
||||
sandbox_status: Some(sandbox_status),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_test_command(command: &str) -> bool {
|
||||
let normalized = command
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.to_ascii_lowercase();
|
||||
normalized.contains("cargo test")
|
||||
|| normalized.contains("cargo nextest")
|
||||
|| normalized.contains("npm test")
|
||||
|| normalized.contains("pnpm test")
|
||||
|| normalized.contains("yarn test")
|
||||
|| normalized.contains("pytest")
|
||||
}
|
||||
|
||||
fn test_timeout_provenance(
|
||||
command: &str,
|
||||
timeout_ms: u64,
|
||||
classified_as_test_hang: bool,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"event": if classified_as_test_hang { "test.hung" } else { "command.timeout" },
|
||||
"failureClass": if classified_as_test_hang { "test_hang" } else { "timeout" },
|
||||
"data": {
|
||||
"command": command,
|
||||
"timeoutMs": timeout_ms,
|
||||
"provenance": "bash.timeout",
|
||||
"classification": if classified_as_test_hang { "test.hung" } else { "timeout" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn sandbox_status_for_input(input: &BashCommandInput, cwd: &std::path::Path) -> SandboxStatus {
|
||||
let config = ConfigLoader::default_for(cwd).load().map_or_else(
|
||||
|_| SandboxConfig::default(),
|
||||
@@ -349,6 +394,31 @@ mod tests {
|
||||
|
||||
assert!(!output.sandbox_status.expect("sandbox status").enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_out_test_command_is_classified_as_hung_test_with_provenance() {
|
||||
let output = execute_bash(BashCommandInput {
|
||||
command: String::from("sleep 1 # cargo test slow_case"),
|
||||
timeout: Some(1),
|
||||
description: None,
|
||||
run_in_background: Some(false),
|
||||
dangerously_disable_sandbox: Some(false),
|
||||
namespace_restrictions: Some(false),
|
||||
isolate_network: Some(false),
|
||||
filesystem_mode: Some(FilesystemIsolationMode::WorkspaceOnly),
|
||||
allowed_mounts: None,
|
||||
})
|
||||
.expect("bash command should return structured timeout");
|
||||
|
||||
assert!(output.interrupted);
|
||||
assert_eq!(
|
||||
output.return_code_interpretation.as_deref(),
|
||||
Some("test.hung")
|
||||
);
|
||||
let structured = output.structured_content.expect("structured content");
|
||||
assert_eq!(structured[0]["event"], "test.hung");
|
||||
assert_eq!(structured[0]["data"]["provenance"], "bash.timeout");
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum output bytes before truncation (16 KiB, matching upstream).
|
||||
|
||||
@@ -212,7 +212,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
|
||||
ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
|
||||
ContentBlock::Text { .. } => None,
|
||||
ContentBlock::Text { .. } | ContentBlock::Thinking { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
tool_names.sort_unstable();
|
||||
@@ -317,6 +317,9 @@ fn merge_compact_summaries(existing_summary: Option<&str>, new_summary: &str) ->
|
||||
fn summarize_block(block: &ContentBlock) -> String {
|
||||
let raw = match block {
|
||||
ContentBlock::Text { text } => text.clone(),
|
||||
ContentBlock::Thinking { thinking, .. } => {
|
||||
format!("thinking ({} chars)", thinking.chars().count())
|
||||
}
|
||||
ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
|
||||
ContentBlock::ToolResult {
|
||||
tool_name,
|
||||
@@ -378,6 +381,7 @@ fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
|
||||
ContentBlock::Text { text } => text.as_str(),
|
||||
ContentBlock::ToolUse { input, .. } => input.as_str(),
|
||||
ContentBlock::ToolResult { output, .. } => output.as_str(),
|
||||
ContentBlock::Thinking { thinking, .. } => thinking.as_str(),
|
||||
})
|
||||
.flat_map(extract_file_candidates)
|
||||
.collect::<Vec<_>>();
|
||||
@@ -400,6 +404,7 @@ fn first_text_block(message: &ConversationMessage) -> Option<&str> {
|
||||
ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
|
||||
ContentBlock::ToolUse { .. }
|
||||
| ContentBlock::ToolResult { .. }
|
||||
| ContentBlock::Thinking { .. }
|
||||
| ContentBlock::Text { .. } => None,
|
||||
})
|
||||
}
|
||||
@@ -450,6 +455,10 @@ fn estimate_message_tokens(message: &ConversationMessage) -> usize {
|
||||
ContentBlock::ToolResult {
|
||||
tool_name, output, ..
|
||||
} => (tool_name.len() + output.len()) / 4 + 1,
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => thinking.len() / 4 + signature.as_ref().map_or(0, |value| value.len() / 4 + 1),
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
@@ -414,6 +414,17 @@ impl RuntimeConfig {
|
||||
pub fn trusted_roots(&self) -> &[String] {
|
||||
&self.feature_config.trusted_roots
|
||||
}
|
||||
|
||||
/// Merge config-level default trusted roots with per-call roots.
|
||||
///
|
||||
/// Config roots are defaults and are kept first; per-call roots extend the
|
||||
/// allowlist for a specific worker/session creation request. Duplicates are
|
||||
/// removed without reordering the first occurrence so evidence remains
|
||||
/// deterministic while avoiding repeated trust checks.
|
||||
#[must_use]
|
||||
pub fn trusted_roots_with_overrides(&self, per_call_roots: &[String]) -> Vec<String> {
|
||||
merge_trusted_roots(self.trusted_roots(), per_call_roots)
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeFeatureConfig {
|
||||
@@ -483,6 +494,22 @@ impl RuntimeFeatureConfig {
|
||||
pub fn trusted_roots(&self) -> &[String] {
|
||||
&self.trusted_roots
|
||||
}
|
||||
|
||||
/// Merge this config's default trusted roots with per-call roots.
|
||||
#[must_use]
|
||||
pub fn trusted_roots_with_overrides(&self, per_call_roots: &[String]) -> Vec<String> {
|
||||
merge_trusted_roots(self.trusted_roots(), per_call_roots)
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_trusted_roots(config_roots: &[String], per_call_roots: &[String]) -> Vec<String> {
|
||||
let mut merged = Vec::with_capacity(config_roots.len() + per_call_roots.len());
|
||||
for root in config_roots.iter().chain(per_call_roots.iter()) {
|
||||
if !merged.contains(root) {
|
||||
merged.push(root.clone());
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
impl ProviderFallbackConfig {
|
||||
@@ -1245,8 +1272,8 @@ fn push_unique(target: &mut Vec<String>, value: String) {
|
||||
mod tests {
|
||||
use super::{
|
||||
deep_merge_objects, parse_permission_mode_label, ConfigLoader, ConfigSource,
|
||||
McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeHookConfig,
|
||||
RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||
McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeFeatureConfig,
|
||||
RuntimeHookConfig, RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
use crate::json::JsonValue;
|
||||
use crate::sandbox::FilesystemIsolationMode;
|
||||
@@ -1254,11 +1281,21 @@ mod tests {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir() -> std::path::PathBuf {
|
||||
// #149: previously used `runtime-config-{nanos}` which collided
|
||||
// under parallel `cargo test --workspace` when multiple tests
|
||||
// started within the same nanosecond bucket on fast machines.
|
||||
// Add process id + a monotonically-incrementing atomic counter
|
||||
// so every callsite gets a provably-unique directory regardless
|
||||
// of clock resolution or scheduling.
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time should be after epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("runtime-config-{nanos}"))
|
||||
let pid = std::process::id();
|
||||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("runtime-config-{pid}-{nanos}-{seq}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1492,6 +1529,51 @@ mod tests {
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_roots_with_overrides_preserves_config_defaults_and_adds_per_call_roots() {
|
||||
// given
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::create_dir_all(&cwd).expect("project dir");
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{"trustedRoots": ["/tmp/config-default", "/tmp/shared"]}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
// when
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load");
|
||||
let merged = loaded.trusted_roots_with_overrides(&[
|
||||
"/tmp/per-call".to_string(),
|
||||
"/tmp/shared".to_string(),
|
||||
]);
|
||||
|
||||
// then
|
||||
assert_eq!(
|
||||
merged,
|
||||
["/tmp/config-default", "/tmp/shared", "/tmp/per-call"]
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_feature_trusted_roots_with_overrides_matches_runtime_config_merge() {
|
||||
let config = RuntimeFeatureConfig {
|
||||
trusted_roots: vec!["/tmp/config".to_string()],
|
||||
..RuntimeFeatureConfig::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
config.trusted_roots_with_overrides(&["/tmp/per-call".to_string()]),
|
||||
["/tmp/config", "/tmp/per-call"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_roots_default_is_empty_when_unset() {
|
||||
// given
|
||||
|
||||
@@ -28,6 +28,10 @@ pub struct ApiRequest {
|
||||
/// Streamed events emitted while processing a single assistant turn.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AssistantEvent {
|
||||
Thinking {
|
||||
thinking: String,
|
||||
signature: Option<String>,
|
||||
},
|
||||
TextDelta(String),
|
||||
ToolUse {
|
||||
id: String,
|
||||
@@ -721,6 +725,16 @@ fn build_assistant_message(
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
AssistantEvent::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
flush_text_block(&mut text, &mut blocks);
|
||||
blocks.push(ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
});
|
||||
}
|
||||
AssistantEvent::TextDelta(delta) => text.push_str(&delta),
|
||||
AssistantEvent::ToolUse { id, name, input } => {
|
||||
flush_text_block(&mut text, &mut blocks);
|
||||
@@ -1723,6 +1737,47 @@ mod tests {
|
||||
.contains("assistant stream produced no content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_assistant_message_places_thinking_block_before_text_and_tool_use() {
|
||||
// given
|
||||
let events = vec![
|
||||
AssistantEvent::Thinking {
|
||||
thinking: "pondering".to_string(),
|
||||
signature: Some("sig".to_string()),
|
||||
},
|
||||
AssistantEvent::TextDelta("hello".to_string()),
|
||||
AssistantEvent::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "echo".to_string(),
|
||||
input: "payload".to_string(),
|
||||
},
|
||||
AssistantEvent::MessageStop,
|
||||
];
|
||||
|
||||
// when
|
||||
let (message, _, _) = build_assistant_message(events)
|
||||
.expect("assistant message should preserve thinking, text, and tool blocks");
|
||||
|
||||
// then
|
||||
assert_eq!(
|
||||
message.blocks,
|
||||
vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "pondering".to_string(),
|
||||
signature: Some("sig".to_string()),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "hello".to_string(),
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "echo".to_string(),
|
||||
input: "payload".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_tool_executor_rejects_unknown_tools() {
|
||||
// given
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -7,7 +8,7 @@ use std::time::Instant;
|
||||
use glob::Pattern;
|
||||
use regex::RegexBuilder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use walkdir::WalkDir;
|
||||
use walkdir::{DirEntry, WalkDir};
|
||||
|
||||
/// Maximum file size that can be read (10 MB).
|
||||
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
|
||||
@@ -15,6 +16,15 @@ const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
|
||||
/// Maximum file size that can be written (10 MB).
|
||||
const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024;
|
||||
|
||||
const GLOB_SEARCH_IGNORED_DIRS: &[&str] = &[
|
||||
".git",
|
||||
"node_modules",
|
||||
".build",
|
||||
"target",
|
||||
"dist",
|
||||
"coverage",
|
||||
];
|
||||
|
||||
/// Check whether a file appears to contain binary content by examining
|
||||
/// the first chunk for NUL bytes.
|
||||
fn is_binary_file(path: &Path) -> io::Result<bool> {
|
||||
@@ -297,11 +307,23 @@ pub fn edit_file(
|
||||
|
||||
/// Expands a glob pattern and returns matching filenames.
|
||||
pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOutput> {
|
||||
glob_search_impl(pattern, path, None)
|
||||
}
|
||||
|
||||
fn glob_search_impl(
|
||||
pattern: &str,
|
||||
path: Option<&str>,
|
||||
workspace_root: Option<&Path>,
|
||||
) -> io::Result<GlobSearchOutput> {
|
||||
let started = Instant::now();
|
||||
let base_dir = path
|
||||
.map(normalize_path)
|
||||
.transpose()?
|
||||
.unwrap_or(std::env::current_dir()?);
|
||||
let canonical_root = workspace_root.map(canonicalize_workspace_root);
|
||||
if let Some(root) = canonical_root.as_deref() {
|
||||
validate_workspace_boundary(&base_dir, root)?;
|
||||
}
|
||||
let search_pattern = if Path::new(pattern).is_absolute() {
|
||||
pattern.to_owned()
|
||||
} else {
|
||||
@@ -313,14 +335,32 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
|
||||
// `Assets/**/*.{cs,uxml,uss}` work correctly.
|
||||
let expanded = expand_braces(&search_pattern);
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut matches = Vec::new();
|
||||
for pat in &expanded {
|
||||
let entries = glob::glob(pat)
|
||||
let compiled = Pattern::new(pat)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
||||
let walk_root = derive_glob_walk_root(pat);
|
||||
if let Some(root) = canonical_root.as_deref() {
|
||||
let canonical_walk_root = walk_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| walk_root.clone());
|
||||
validate_workspace_boundary(&canonical_walk_root, root)?;
|
||||
}
|
||||
let entries = WalkDir::new(&walk_root)
|
||||
.into_iter()
|
||||
.filter_entry(|entry| !should_skip_glob_dir(entry));
|
||||
for entry in entries.flatten() {
|
||||
if entry.is_file() && seen.insert(entry.clone()) {
|
||||
matches.push(entry);
|
||||
let candidate = entry.path();
|
||||
if entry.file_type().is_file()
|
||||
&& compiled.matches_path(candidate)
|
||||
&& seen.insert(candidate.to_path_buf())
|
||||
{
|
||||
if let Some(root) = canonical_root.as_deref() {
|
||||
let canonical_candidate = candidate.canonicalize()?;
|
||||
validate_workspace_boundary(&canonical_candidate, root)?;
|
||||
}
|
||||
matches.push(candidate.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,12 +389,23 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
|
||||
|
||||
/// Runs a regex search over workspace files with optional context lines.
|
||||
pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
||||
grep_search_impl(input, None)
|
||||
}
|
||||
|
||||
fn grep_search_impl(
|
||||
input: &GrepSearchInput,
|
||||
workspace_root: Option<&Path>,
|
||||
) -> io::Result<GrepSearchOutput> {
|
||||
let base_path = input
|
||||
.path
|
||||
.as_deref()
|
||||
.map(normalize_path)
|
||||
.transpose()?
|
||||
.unwrap_or(std::env::current_dir()?);
|
||||
let canonical_root = workspace_root.map(canonicalize_workspace_root);
|
||||
if let Some(root) = canonical_root.as_deref() {
|
||||
validate_workspace_boundary(&base_path, root)?;
|
||||
}
|
||||
|
||||
let regex = RegexBuilder::new(&input.pattern)
|
||||
.case_insensitive(input.case_insensitive.unwrap_or(false))
|
||||
@@ -380,6 +431,10 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
||||
let mut total_matches = 0usize;
|
||||
|
||||
for file_path in collect_search_files(&base_path)? {
|
||||
if let Some(root) = canonical_root.as_deref() {
|
||||
let canonical_file = file_path.canonicalize()?;
|
||||
validate_workspace_boundary(&canonical_file, root)?;
|
||||
}
|
||||
if !matches_optional_filters(&file_path, glob_filter.as_ref(), file_type) {
|
||||
continue;
|
||||
}
|
||||
@@ -429,27 +484,21 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
||||
|
||||
let (filenames, applied_limit, applied_offset) =
|
||||
apply_limit(filenames, input.head_limit, input.offset);
|
||||
let content_output = if output_mode == "content" {
|
||||
let (lines, limit, offset) = apply_limit(content_lines, input.head_limit, input.offset);
|
||||
return Ok(GrepSearchOutput {
|
||||
mode: Some(output_mode),
|
||||
num_files: filenames.len(),
|
||||
if output_mode == "content" {
|
||||
return Ok(build_grep_content_output(
|
||||
output_mode,
|
||||
filenames,
|
||||
num_lines: Some(lines.len()),
|
||||
content: Some(lines.join("\n")),
|
||||
num_matches: None,
|
||||
applied_limit: limit,
|
||||
applied_offset: offset,
|
||||
});
|
||||
} else {
|
||||
None
|
||||
};
|
||||
content_lines,
|
||||
input.head_limit,
|
||||
input.offset,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(GrepSearchOutput {
|
||||
mode: Some(output_mode.clone()),
|
||||
num_files: filenames.len(),
|
||||
filenames,
|
||||
content: content_output,
|
||||
content: None,
|
||||
num_lines: None,
|
||||
num_matches: (output_mode == "count").then_some(total_matches),
|
||||
applied_limit,
|
||||
@@ -457,6 +506,65 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
||||
})
|
||||
}
|
||||
|
||||
fn build_grep_content_output(
|
||||
output_mode: String,
|
||||
filenames: Vec<String>,
|
||||
content_lines: Vec<String>,
|
||||
head_limit: Option<usize>,
|
||||
offset: Option<usize>,
|
||||
) -> GrepSearchOutput {
|
||||
let (lines, limit, offset) = apply_limit(content_lines, head_limit, offset);
|
||||
GrepSearchOutput {
|
||||
mode: Some(output_mode),
|
||||
num_files: filenames.len(),
|
||||
filenames,
|
||||
num_lines: Some(lines.len()),
|
||||
content: Some(lines.join("\n")),
|
||||
num_matches: None,
|
||||
applied_limit: limit,
|
||||
applied_offset: offset,
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_workspace_root(workspace_root: &Path) -> PathBuf {
|
||||
workspace_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace_root.to_path_buf())
|
||||
}
|
||||
|
||||
fn should_skip_glob_dir(entry: &DirEntry) -> bool {
|
||||
entry.file_type().is_dir()
|
||||
&& entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| GLOB_SEARCH_IGNORED_DIRS.contains(&name))
|
||||
}
|
||||
|
||||
fn derive_glob_walk_root(pattern: &str) -> PathBuf {
|
||||
let path = Path::new(pattern);
|
||||
let mut prefix = PathBuf::new();
|
||||
let mut saw_component = false;
|
||||
|
||||
for component in path.components() {
|
||||
let text = component.as_os_str().to_string_lossy();
|
||||
if component_contains_glob(&text) {
|
||||
break;
|
||||
}
|
||||
prefix.push(component.as_os_str());
|
||||
saw_component = true;
|
||||
}
|
||||
|
||||
if saw_component {
|
||||
prefix
|
||||
} else {
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
}
|
||||
}
|
||||
|
||||
fn component_contains_glob(component: &str) -> bool {
|
||||
component.contains('*') || component.contains('?') || component.contains('[')
|
||||
}
|
||||
|
||||
fn collect_search_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
|
||||
if base_path.is_file() {
|
||||
return Ok(vec![base_path.to_path_buf()]);
|
||||
@@ -574,9 +682,7 @@ pub fn read_file_in_workspace(
|
||||
workspace_root: &Path,
|
||||
) -> io::Result<ReadFileOutput> {
|
||||
let absolute_path = normalize_path(path)?;
|
||||
let canonical_root = workspace_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace_root.to_path_buf());
|
||||
let canonical_root = canonicalize_workspace_root(workspace_root);
|
||||
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
||||
read_file(path, offset, limit)
|
||||
}
|
||||
@@ -589,9 +695,7 @@ pub fn write_file_in_workspace(
|
||||
workspace_root: &Path,
|
||||
) -> io::Result<WriteFileOutput> {
|
||||
let absolute_path = normalize_path_allow_missing(path)?;
|
||||
let canonical_root = workspace_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace_root.to_path_buf());
|
||||
let canonical_root = canonicalize_workspace_root(workspace_root);
|
||||
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
||||
write_file(path, content)
|
||||
}
|
||||
@@ -606,13 +710,30 @@ pub fn edit_file_in_workspace(
|
||||
workspace_root: &Path,
|
||||
) -> io::Result<EditFileOutput> {
|
||||
let absolute_path = normalize_path(path)?;
|
||||
let canonical_root = workspace_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace_root.to_path_buf());
|
||||
let canonical_root = canonicalize_workspace_root(workspace_root);
|
||||
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
||||
edit_file(path, old_string, new_string, replace_all)
|
||||
}
|
||||
|
||||
/// Expand a glob pattern with workspace boundary enforcement.
|
||||
#[allow(dead_code)]
|
||||
pub fn glob_search_in_workspace(
|
||||
pattern: &str,
|
||||
path: Option<&str>,
|
||||
workspace_root: &Path,
|
||||
) -> io::Result<GlobSearchOutput> {
|
||||
glob_search_impl(pattern, path, Some(workspace_root))
|
||||
}
|
||||
|
||||
/// Search file contents with workspace boundary enforcement.
|
||||
#[allow(dead_code)]
|
||||
pub fn grep_search_in_workspace(
|
||||
input: &GrepSearchInput,
|
||||
workspace_root: &Path,
|
||||
) -> io::Result<GrepSearchOutput> {
|
||||
grep_search_impl(input, Some(workspace_root))
|
||||
}
|
||||
|
||||
/// Check whether a path is a symlink that resolves outside the workspace.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_symlink_escape(path: &Path, workspace_root: &Path) -> io::Result<bool> {
|
||||
@@ -651,11 +772,13 @@ fn expand_braces(pattern: &str) -> Vec<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::{
|
||||
edit_file, expand_braces, glob_search, grep_search, is_symlink_escape, read_file,
|
||||
read_file_in_workspace, write_file, GrepSearchInput, MAX_WRITE_SIZE,
|
||||
component_contains_glob, derive_glob_walk_root, edit_file, expand_braces, glob_search,
|
||||
grep_search, is_symlink_escape, read_file, read_file_in_workspace, write_file,
|
||||
write_file_in_workspace, GrepSearchInput, MAX_WRITE_SIZE,
|
||||
};
|
||||
|
||||
fn temp_path(name: &str) -> std::path::PathBuf {
|
||||
@@ -755,6 +878,68 @@ mod tests {
|
||||
assert!(!is_symlink_escape(&normal, &workspace).expect("check should succeed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn workspace_read_rejects_symlink_escape_regression_3007_class() {
|
||||
let workspace = temp_path("workspace-read-symlink-escape");
|
||||
let outside = temp_path("workspace-read-symlink-target");
|
||||
std::fs::create_dir_all(&workspace).expect("workspace dir should be created");
|
||||
std::fs::create_dir_all(&outside).expect("outside dir should be created");
|
||||
let outside_file = outside.join("secret.txt");
|
||||
std::fs::write(&outside_file, "outside secret").expect("outside file should write");
|
||||
|
||||
let link_path = workspace.join("linked-secret.txt");
|
||||
std::os::unix::fs::symlink(&outside_file, &link_path).expect("symlink should create");
|
||||
|
||||
let result =
|
||||
read_file_in_workspace(link_path.to_string_lossy().as_ref(), None, None, &workspace);
|
||||
|
||||
assert!(result.is_err(), "symlink escape must be rejected");
|
||||
let error = result.unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
assert!(
|
||||
error.to_string().contains("escapes workspace"),
|
||||
"error should explain workspace escape: {error}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&workspace);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn workspace_write_rejects_parent_symlink_escape_regression_3007_class() {
|
||||
let workspace = temp_path("workspace-write-symlink-escape");
|
||||
let outside = temp_path("workspace-write-symlink-target");
|
||||
std::fs::create_dir_all(&workspace).expect("workspace dir should be created");
|
||||
std::fs::create_dir_all(&outside).expect("outside dir should be created");
|
||||
|
||||
let link_dir = workspace.join("linked-outside");
|
||||
std::os::unix::fs::symlink(&outside, &link_dir).expect("symlink dir should create");
|
||||
let escaped_child = link_dir.join("created.txt");
|
||||
|
||||
let result = write_file_in_workspace(
|
||||
escaped_child.to_string_lossy().as_ref(),
|
||||
"must not escape",
|
||||
&workspace,
|
||||
);
|
||||
|
||||
assert!(result.is_err(), "parent symlink escape must be rejected");
|
||||
let error = result.unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
assert!(
|
||||
error.to_string().contains("escapes workspace"),
|
||||
"error should explain workspace escape: {error}"
|
||||
);
|
||||
assert!(
|
||||
!outside.join("created.txt").exists(),
|
||||
"write should not create through an escaping symlink"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&workspace);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn globs_and_greps_directory() {
|
||||
let dir = temp_path("search-dir");
|
||||
@@ -836,4 +1021,50 @@ mod tests {
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_search_skips_common_heavy_directories() {
|
||||
let dir = temp_path("glob-ignored-dirs");
|
||||
std::fs::create_dir_all(dir.join("src")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("docs")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("node_modules/pkg")).unwrap();
|
||||
std::fs::create_dir_all(dir.join(".build/checkouts/pkg")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("target/debug/deps")).unwrap();
|
||||
|
||||
std::fs::write(dir.join("src/AGENTS.md"), "src").unwrap();
|
||||
std::fs::write(dir.join("docs/AGENTS.md"), "docs").unwrap();
|
||||
std::fs::write(dir.join("node_modules/pkg/AGENTS.md"), "node_modules").unwrap();
|
||||
std::fs::write(dir.join(".build/checkouts/pkg/AGENTS.md"), ".build").unwrap();
|
||||
std::fs::write(dir.join("target/debug/deps/AGENTS.md"), "target").unwrap();
|
||||
|
||||
let result =
|
||||
glob_search("**/AGENTS.md", Some(dir.to_str().unwrap())).expect("glob should succeed");
|
||||
|
||||
assert_eq!(result.num_files, 2, "ignored dirs should be pruned");
|
||||
assert!(result
|
||||
.filenames
|
||||
.iter()
|
||||
.any(|path| path.ends_with("src/AGENTS.md")));
|
||||
assert!(result
|
||||
.filenames
|
||||
.iter()
|
||||
.any(|path| path.ends_with("docs/AGENTS.md")));
|
||||
assert!(!result
|
||||
.filenames
|
||||
.iter()
|
||||
.any(|path| path.contains("node_modules")
|
||||
|| path.contains(".build")
|
||||
|| path.contains("/target/")));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_glob_walk_root_stops_at_first_glob_component() {
|
||||
let root = derive_glob_walk_root("/tmp/demo/**/AGENTS.md");
|
||||
assert_eq!(root, PathBuf::from("/tmp/demo"));
|
||||
assert!(component_contains_glob("**"));
|
||||
assert!(component_contains_glob("*.rs"));
|
||||
assert!(!component_contains_glob("src"));
|
||||
}
|
||||
}
|
||||
|
||||
399
rust/crates/runtime/src/g004_conformance.rs
Normal file
399
rust/crates/runtime/src/g004_conformance.rs
Normal file
@@ -0,0 +1,399 @@
|
||||
//! Machine-checkable conformance helpers for G004 event/report contract bundles.
|
||||
//!
|
||||
//! The harness intentionally validates JSON-shaped artifacts instead of owning the
|
||||
//! lane-event, report, or approval-token implementations. This keeps it usable by
|
||||
//! independent implementation lanes and by golden fixtures produced outside the
|
||||
//! runtime crate.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const BUNDLE_SCHEMA_VERSION: &str = "g004.contract.bundle.v1";
|
||||
const REPORT_SCHEMA_VERSION: &str = "g004.report.v1";
|
||||
|
||||
/// A single conformance validation failure.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct G004ConformanceError {
|
||||
/// JSON pointer-ish path to the invalid field.
|
||||
pub path: String,
|
||||
/// Human-readable reason the field failed validation.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl G004ConformanceError {
|
||||
fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a G004 golden contract bundle.
|
||||
///
|
||||
/// The bundle shape is deliberately small and cross-lane:
|
||||
/// - `laneEvents[]` must expose stable event identity, ordering/provenance, and
|
||||
/// terminal dedupe fingerprints.
|
||||
/// - `reports[]` must expose schema identity, content hash, projection/redaction
|
||||
/// provenance, capability negotiation, fact/hypothesis/negative-evidence
|
||||
/// labels, confidence, and field-level delta attribution.
|
||||
/// - `approvalTokens[]` must expose owner/scope, delegation chain, one-time-use,
|
||||
/// and replay-prevention fields.
|
||||
#[must_use]
|
||||
pub fn validate_g004_contract_bundle(bundle: &Value) -> Vec<G004ConformanceError> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
require_string_eq(bundle, "/schemaVersion", BUNDLE_SCHEMA_VERSION, &mut errors);
|
||||
validate_lane_events(bundle.get("laneEvents"), "/laneEvents", &mut errors);
|
||||
validate_reports(bundle.get("reports"), "/reports", &mut errors);
|
||||
validate_approval_tokens(bundle.get("approvalTokens"), "/approvalTokens", &mut errors);
|
||||
|
||||
errors
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_g004_contract_bundle_valid(bundle: &Value) -> bool {
|
||||
validate_g004_contract_bundle(bundle).is_empty()
|
||||
}
|
||||
|
||||
fn validate_lane_events(value: Option<&Value>, path: &str, errors: &mut Vec<G004ConformanceError>) {
|
||||
let Some(events) = non_empty_array(value, path, errors) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut previous_seq = None;
|
||||
for (index, event) in events.iter().enumerate() {
|
||||
let base = format!("{path}/{index}");
|
||||
require_non_empty_string_at(event, "/event", &format!("{base}/event"), errors);
|
||||
require_non_empty_string_at(event, "/status", &format!("{base}/status"), errors);
|
||||
require_non_empty_string_at(event, "/emittedAt", &format!("{base}/emittedAt"), errors);
|
||||
require_non_empty_string_at(
|
||||
event,
|
||||
"/metadata/provenance",
|
||||
&format!("{base}/metadata/provenance"),
|
||||
errors,
|
||||
);
|
||||
require_non_empty_string_at(
|
||||
event,
|
||||
"/metadata/emitterIdentity",
|
||||
&format!("{base}/metadata/emitterIdentity"),
|
||||
errors,
|
||||
);
|
||||
require_non_empty_string_at(
|
||||
event,
|
||||
"/metadata/environmentLabel",
|
||||
&format!("{base}/metadata/environmentLabel"),
|
||||
errors,
|
||||
);
|
||||
|
||||
match get_path(event, "/metadata/seq").and_then(Value::as_u64) {
|
||||
Some(seq) => {
|
||||
if let Some(previous) = previous_seq {
|
||||
if seq <= previous {
|
||||
errors.push(G004ConformanceError::new(
|
||||
format!("{base}/metadata/seq"),
|
||||
"sequence must be strictly increasing",
|
||||
));
|
||||
}
|
||||
}
|
||||
previous_seq = Some(seq);
|
||||
}
|
||||
None => errors.push(G004ConformanceError::new(
|
||||
format!("{base}/metadata/seq"),
|
||||
"required u64 field missing",
|
||||
)),
|
||||
}
|
||||
|
||||
if is_terminal_event_value(event.get("event")) {
|
||||
require_non_empty_string_at(
|
||||
event,
|
||||
"/metadata/eventFingerprint",
|
||||
&format!("{base}/metadata/eventFingerprint"),
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_reports(value: Option<&Value>, path: &str, errors: &mut Vec<G004ConformanceError>) {
|
||||
let Some(reports) = non_empty_array(value, path, errors) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (index, report) in reports.iter().enumerate() {
|
||||
let base = format!("{path}/{index}");
|
||||
require_string_eq_at(
|
||||
report,
|
||||
"/schemaVersion",
|
||||
&format!("{base}/schemaVersion"),
|
||||
REPORT_SCHEMA_VERSION,
|
||||
errors,
|
||||
);
|
||||
require_non_empty_string_at(report, "/reportId", &format!("{base}/reportId"), errors);
|
||||
require_non_empty_string_at(
|
||||
report,
|
||||
"/identity/contentHash",
|
||||
&format!("{base}/identity/contentHash"),
|
||||
errors,
|
||||
);
|
||||
require_non_empty_string_at(
|
||||
report,
|
||||
"/projection/provenance",
|
||||
&format!("{base}/projection/provenance"),
|
||||
errors,
|
||||
);
|
||||
require_non_empty_string_at(
|
||||
report,
|
||||
"/redaction/provenance",
|
||||
&format!("{base}/redaction/provenance"),
|
||||
errors,
|
||||
);
|
||||
non_empty_array(
|
||||
get_path(report, "/consumerCapabilities"),
|
||||
&format!("{base}/consumerCapabilities"),
|
||||
errors,
|
||||
);
|
||||
validate_findings(
|
||||
get_path(report, "/findings"),
|
||||
&format!("{base}/findings"),
|
||||
errors,
|
||||
);
|
||||
validate_field_deltas(
|
||||
get_path(report, "/fieldDeltas"),
|
||||
&format!("{base}/fieldDeltas"),
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_findings(value: Option<&Value>, path: &str, errors: &mut Vec<G004ConformanceError>) {
|
||||
let Some(findings) = non_empty_array(value, path, errors) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (index, finding) in findings.iter().enumerate() {
|
||||
let base = format!("{path}/{index}");
|
||||
require_one_of_at(
|
||||
finding,
|
||||
"/kind",
|
||||
&format!("{base}/kind"),
|
||||
&["fact", "hypothesis", "negative_evidence"],
|
||||
errors,
|
||||
);
|
||||
require_one_of_at(
|
||||
finding,
|
||||
"/confidence",
|
||||
&format!("{base}/confidence"),
|
||||
&["low", "medium", "high"],
|
||||
errors,
|
||||
);
|
||||
require_non_empty_string_at(finding, "/statement", &format!("{base}/statement"), errors);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_field_deltas(
|
||||
value: Option<&Value>,
|
||||
path: &str,
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) {
|
||||
let Some(deltas) = non_empty_array(value, path, errors) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (index, delta) in deltas.iter().enumerate() {
|
||||
let base = format!("{path}/{index}");
|
||||
require_non_empty_string_at(delta, "/field", &format!("{base}/field"), errors);
|
||||
require_non_empty_string_at(
|
||||
delta,
|
||||
"/previousHash",
|
||||
&format!("{base}/previousHash"),
|
||||
errors,
|
||||
);
|
||||
require_non_empty_string_at(
|
||||
delta,
|
||||
"/currentHash",
|
||||
&format!("{base}/currentHash"),
|
||||
errors,
|
||||
);
|
||||
require_non_empty_string_at(
|
||||
delta,
|
||||
"/attribution",
|
||||
&format!("{base}/attribution"),
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_approval_tokens(
|
||||
value: Option<&Value>,
|
||||
path: &str,
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) {
|
||||
let Some(tokens) = non_empty_array(value, path, errors) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (index, token) in tokens.iter().enumerate() {
|
||||
let base = format!("{path}/{index}");
|
||||
require_non_empty_string_at(token, "/tokenId", &format!("{base}/tokenId"), errors);
|
||||
require_non_empty_string_at(token, "/owner", &format!("{base}/owner"), errors);
|
||||
require_non_empty_string_at(token, "/scope", &format!("{base}/scope"), errors);
|
||||
require_non_empty_string_at(token, "/issuedAt", &format!("{base}/issuedAt"), errors);
|
||||
require_bool_true_at(token, "/oneTimeUse", &format!("{base}/oneTimeUse"), errors);
|
||||
require_non_empty_string_at(
|
||||
token,
|
||||
"/replayPreventionNonce",
|
||||
&format!("{base}/replayPreventionNonce"),
|
||||
errors,
|
||||
);
|
||||
validate_delegation_chain(
|
||||
get_path(token, "/delegationChain"),
|
||||
&format!("{base}/delegationChain"),
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_delegation_chain(
|
||||
value: Option<&Value>,
|
||||
path: &str,
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) {
|
||||
let Some(chain) = non_empty_array(value, path, errors) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (index, hop) in chain.iter().enumerate() {
|
||||
let base = format!("{path}/{index}");
|
||||
require_non_empty_string_at(hop, "/from", &format!("{base}/from"), errors);
|
||||
require_non_empty_string_at(hop, "/to", &format!("{base}/to"), errors);
|
||||
require_non_empty_string_at(hop, "/action", &format!("{base}/action"), errors);
|
||||
require_non_empty_string_at(hop, "/at", &format!("{base}/at"), errors);
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_array<'a>(
|
||||
value: Option<&'a Value>,
|
||||
path: &str,
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) -> Option<&'a Vec<Value>> {
|
||||
match value.and_then(Value::as_array) {
|
||||
Some(array) if !array.is_empty() => Some(array),
|
||||
Some(_) => {
|
||||
errors.push(G004ConformanceError::new(path, "array must not be empty"));
|
||||
None
|
||||
}
|
||||
None => {
|
||||
errors.push(G004ConformanceError::new(
|
||||
path,
|
||||
"required array field missing",
|
||||
));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn require_string_eq(
|
||||
root: &Value,
|
||||
path: &str,
|
||||
expected: &str,
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) {
|
||||
require_string_eq_at(root, path, path, expected, errors);
|
||||
}
|
||||
|
||||
fn require_string_eq_at(
|
||||
root: &Value,
|
||||
pointer: &str,
|
||||
error_path: &str,
|
||||
expected: &str,
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) {
|
||||
match get_path(root, pointer).and_then(Value::as_str) {
|
||||
Some(actual) if actual == expected => {}
|
||||
Some(actual) => errors.push(G004ConformanceError::new(
|
||||
error_path,
|
||||
format!("expected '{expected}', got '{actual}'"),
|
||||
)),
|
||||
None => errors.push(G004ConformanceError::new(
|
||||
error_path,
|
||||
"required string field missing",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn require_non_empty_string_at(
|
||||
root: &Value,
|
||||
pointer: &str,
|
||||
error_path: &str,
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) {
|
||||
match get_path(root, pointer).and_then(Value::as_str) {
|
||||
Some(value) if !value.trim().is_empty() => {}
|
||||
Some(_) => errors.push(G004ConformanceError::new(
|
||||
error_path,
|
||||
"string must not be empty",
|
||||
)),
|
||||
None => errors.push(G004ConformanceError::new(
|
||||
error_path,
|
||||
"required string field missing",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn require_one_of_at(
|
||||
root: &Value,
|
||||
pointer: &str,
|
||||
error_path: &str,
|
||||
allowed: &[&str],
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) {
|
||||
match get_path(root, pointer).and_then(Value::as_str) {
|
||||
Some(value) if allowed.contains(&value) => {}
|
||||
Some(value) => errors.push(G004ConformanceError::new(
|
||||
error_path,
|
||||
format!("'{value}' is not one of {}", allowed.join(", ")),
|
||||
)),
|
||||
None => errors.push(G004ConformanceError::new(
|
||||
error_path,
|
||||
"required string field missing",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn require_bool_true_at(
|
||||
root: &Value,
|
||||
pointer: &str,
|
||||
error_path: &str,
|
||||
errors: &mut Vec<G004ConformanceError>,
|
||||
) {
|
||||
match get_path(root, pointer).and_then(Value::as_bool) {
|
||||
Some(true) => {}
|
||||
Some(false) => errors.push(G004ConformanceError::new(error_path, "must be true")),
|
||||
None => errors.push(G004ConformanceError::new(
|
||||
error_path,
|
||||
"required boolean field missing",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_terminal_event_value(value: Option<&Value>) -> bool {
|
||||
matches!(
|
||||
value.and_then(Value::as_str),
|
||||
Some("lane.finished" | "lane.failed" | "lane.merged" | "lane.superseded" | "lane.closed")
|
||||
)
|
||||
}
|
||||
|
||||
fn get_path<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
|
||||
if let Some(value) = root.pointer(path) {
|
||||
return Some(value);
|
||||
}
|
||||
|
||||
let segments = path.trim_start_matches('/').split('/').collect::<Vec<_>>();
|
||||
for index in 1..segments.len() {
|
||||
let relative = format!("/{}", segments[index..].join("/"));
|
||||
if let Some(value) = root.pointer(&relative) {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -27,19 +27,38 @@ impl std::fmt::Display for GreenLevel {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GreenContract {
|
||||
pub required_level: GreenLevel,
|
||||
pub requirements: Vec<GreenContractRequirement>,
|
||||
pub block_known_flakes: bool,
|
||||
}
|
||||
|
||||
impl GreenContract {
|
||||
#[must_use]
|
||||
pub fn new(required_level: GreenLevel) -> Self {
|
||||
Self { required_level }
|
||||
Self {
|
||||
required_level,
|
||||
requirements: Vec::new(),
|
||||
block_known_flakes: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn evaluate(self, observed_level: Option<GreenLevel>) -> GreenContractOutcome {
|
||||
pub fn merge_ready(required_level: GreenLevel) -> Self {
|
||||
Self {
|
||||
required_level,
|
||||
requirements: vec![
|
||||
GreenContractRequirement::TestCommandProvenance,
|
||||
GreenContractRequirement::BaseBranchFreshness,
|
||||
GreenContractRequirement::RecoveryAttemptContext,
|
||||
],
|
||||
block_known_flakes: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn evaluate(&self, observed_level: Option<GreenLevel>) -> GreenContractOutcome {
|
||||
match observed_level {
|
||||
Some(level) if level >= self.required_level => GreenContractOutcome::Satisfied {
|
||||
required_level: self.required_level,
|
||||
@@ -53,11 +72,170 @@ impl GreenContract {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_satisfied_by(self, observed_level: GreenLevel) -> bool {
|
||||
pub fn evaluate_evidence(&self, evidence: &GreenEvidence) -> GreenEvidenceOutcome {
|
||||
let mut missing = Vec::new();
|
||||
let mut blocking_flakes = Vec::new();
|
||||
|
||||
if evidence.observed_level < self.required_level {
|
||||
missing.push(GreenContractRequirement::RequiredLevel);
|
||||
}
|
||||
|
||||
for requirement in &self.requirements {
|
||||
match requirement {
|
||||
GreenContractRequirement::TestCommandProvenance
|
||||
if !evidence.has_passing_test_command() =>
|
||||
{
|
||||
missing.push(*requirement);
|
||||
}
|
||||
GreenContractRequirement::BaseBranchFreshness if !evidence.base_branch_fresh => {
|
||||
missing.push(*requirement);
|
||||
}
|
||||
GreenContractRequirement::RecoveryAttemptContext
|
||||
if !evidence.recovery_attempt_context_recorded =>
|
||||
{
|
||||
missing.push(*requirement);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if self.block_known_flakes {
|
||||
blocking_flakes = evidence
|
||||
.known_flakes
|
||||
.iter()
|
||||
.filter(|flake| flake.blocks_green)
|
||||
.cloned()
|
||||
.collect();
|
||||
}
|
||||
|
||||
if missing.is_empty() && blocking_flakes.is_empty() {
|
||||
GreenEvidenceOutcome::Satisfied {
|
||||
required_level: self.required_level,
|
||||
observed_level: evidence.observed_level,
|
||||
}
|
||||
} else {
|
||||
GreenEvidenceOutcome::Unsatisfied {
|
||||
required_level: self.required_level,
|
||||
observed_level: evidence.observed_level,
|
||||
missing,
|
||||
blocking_flakes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_satisfied_by(&self, observed_level: GreenLevel) -> bool {
|
||||
observed_level >= self.required_level
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GreenEvidence {
|
||||
pub observed_level: GreenLevel,
|
||||
pub test_commands: Vec<TestCommandProvenance>,
|
||||
pub base_branch_fresh: bool,
|
||||
pub known_flakes: Vec<KnownFlake>,
|
||||
pub recovery_attempt_context_recorded: bool,
|
||||
}
|
||||
|
||||
impl GreenEvidence {
|
||||
#[must_use]
|
||||
pub fn new(observed_level: GreenLevel) -> Self {
|
||||
Self {
|
||||
observed_level,
|
||||
test_commands: Vec::new(),
|
||||
base_branch_fresh: false,
|
||||
known_flakes: Vec::new(),
|
||||
recovery_attempt_context_recorded: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_test_command(mut self, command: impl Into<String>, exit_code: i32) -> Self {
|
||||
self.test_commands.push(TestCommandProvenance {
|
||||
command: command.into(),
|
||||
exit_code,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_base_branch_fresh(mut self, is_fresh: bool) -> Self {
|
||||
self.base_branch_fresh = is_fresh;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_known_flake(mut self, test_name: impl Into<String>, blocks_green: bool) -> Self {
|
||||
self.known_flakes.push(KnownFlake {
|
||||
test_name: test_name.into(),
|
||||
blocks_green,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_recovery_attempt_context(mut self, recorded: bool) -> Self {
|
||||
self.recovery_attempt_context_recorded = recorded;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn has_passing_test_command(&self) -> bool {
|
||||
self.test_commands.iter().any(TestCommandProvenance::passed)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TestCommandProvenance {
|
||||
pub command: String,
|
||||
pub exit_code: i32,
|
||||
}
|
||||
|
||||
impl TestCommandProvenance {
|
||||
#[must_use]
|
||||
pub fn passed(&self) -> bool {
|
||||
self.exit_code == 0 && !self.command.trim().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KnownFlake {
|
||||
pub test_name: String,
|
||||
pub blocks_green: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GreenContractRequirement {
|
||||
RequiredLevel,
|
||||
TestCommandProvenance,
|
||||
BaseBranchFreshness,
|
||||
RecoveryAttemptContext,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "outcome", rename_all = "snake_case")]
|
||||
pub enum GreenEvidenceOutcome {
|
||||
Satisfied {
|
||||
required_level: GreenLevel,
|
||||
observed_level: GreenLevel,
|
||||
},
|
||||
Unsatisfied {
|
||||
required_level: GreenLevel,
|
||||
observed_level: GreenLevel,
|
||||
missing: Vec<GreenContractRequirement>,
|
||||
blocking_flakes: Vec<KnownFlake>,
|
||||
},
|
||||
}
|
||||
|
||||
impl GreenEvidenceOutcome {
|
||||
#[must_use]
|
||||
pub fn is_satisfied(&self) -> bool {
|
||||
matches!(self, Self::Satisfied { .. })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "outcome", rename_all = "snake_case")]
|
||||
pub enum GreenContractOutcome {
|
||||
@@ -149,4 +327,83 @@ mod tests {
|
||||
}
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn merge_ready_contract_requires_provenance_beyond_test_level() {
|
||||
// given
|
||||
let contract = GreenContract::merge_ready(GreenLevel::Workspace);
|
||||
let evidence = GreenEvidence::new(GreenLevel::Workspace)
|
||||
.with_test_command("cargo test --manifest-path rust/Cargo.toml", 0);
|
||||
|
||||
// when
|
||||
let outcome = contract.evaluate_evidence(&evidence);
|
||||
|
||||
// then
|
||||
assert_eq!(
|
||||
outcome,
|
||||
GreenEvidenceOutcome::Unsatisfied {
|
||||
required_level: GreenLevel::Workspace,
|
||||
observed_level: GreenLevel::Workspace,
|
||||
missing: vec![
|
||||
GreenContractRequirement::BaseBranchFreshness,
|
||||
GreenContractRequirement::RecoveryAttemptContext,
|
||||
],
|
||||
blocking_flakes: vec![],
|
||||
}
|
||||
);
|
||||
assert!(!outcome.is_satisfied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_ready_contract_accepts_complete_test_provenance_context() {
|
||||
// given
|
||||
let contract = GreenContract::merge_ready(GreenLevel::Workspace);
|
||||
let evidence = GreenEvidence::new(GreenLevel::MergeReady)
|
||||
.with_test_command("cargo test --manifest-path rust/Cargo.toml", 0)
|
||||
.with_base_branch_fresh(true)
|
||||
.with_recovery_attempt_context(true);
|
||||
|
||||
// when
|
||||
let outcome = contract.evaluate_evidence(&evidence);
|
||||
|
||||
// then
|
||||
assert_eq!(
|
||||
outcome,
|
||||
GreenEvidenceOutcome::Satisfied {
|
||||
required_level: GreenLevel::Workspace,
|
||||
observed_level: GreenLevel::MergeReady,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_blocking_flake_prevents_green_contract_satisfaction() {
|
||||
// given
|
||||
let contract = GreenContract::merge_ready(GreenLevel::Workspace);
|
||||
let evidence = GreenEvidence::new(GreenLevel::MergeReady)
|
||||
.with_test_command("cargo test --manifest-path rust/Cargo.toml", 0)
|
||||
.with_base_branch_fresh(true)
|
||||
.with_recovery_attempt_context(true)
|
||||
.with_known_flake(
|
||||
"session_lifecycle_prefers_running_process_over_idle_shell",
|
||||
true,
|
||||
);
|
||||
|
||||
// when
|
||||
let outcome = contract.evaluate_evidence(&evidence);
|
||||
|
||||
// then
|
||||
assert_eq!(
|
||||
outcome,
|
||||
GreenEvidenceOutcome::Unsatisfied {
|
||||
required_level: GreenLevel::Workspace,
|
||||
observed_level: GreenLevel::MergeReady,
|
||||
missing: vec![],
|
||||
blocking_flakes: vec![KnownFlake {
|
||||
test_name: "session_lifecycle_prefers_running_process_over_idle_shell"
|
||||
.to_string(),
|
||||
blocks_green: true,
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
||||
//! MCP plumbing, tool-facing file operations, and the core conversation loop
|
||||
//! that drives interactive and one-shot turns.
|
||||
|
||||
mod approval_tokens;
|
||||
mod bash;
|
||||
pub mod bash_validation;
|
||||
mod bootstrap;
|
||||
@@ -13,6 +14,7 @@ mod config;
|
||||
pub mod config_validate;
|
||||
mod conversation;
|
||||
mod file_ops;
|
||||
pub mod g004_conformance;
|
||||
mod git_context;
|
||||
pub mod green_contract;
|
||||
mod hooks;
|
||||
@@ -33,6 +35,7 @@ mod policy_engine;
|
||||
mod prompt;
|
||||
pub mod recovery_recipes;
|
||||
mod remote;
|
||||
mod report_schema;
|
||||
pub mod sandbox;
|
||||
mod session;
|
||||
pub mod session_control;
|
||||
@@ -49,6 +52,10 @@ mod trust_resolver;
|
||||
mod usage;
|
||||
pub mod worker_boot;
|
||||
|
||||
pub use approval_tokens::{
|
||||
ApprovalDelegationHop, ApprovalScope, ApprovalTokenAudit, ApprovalTokenError,
|
||||
ApprovalTokenGrant, ApprovalTokenLedger, ApprovalTokenStatus,
|
||||
};
|
||||
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
|
||||
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
|
||||
pub use branch_lock::{detect_branch_lock_collisions, BranchLockCollision, BranchLockIntent};
|
||||
@@ -74,9 +81,10 @@ pub use conversation::{
|
||||
ToolExecutor, TurnSummary,
|
||||
};
|
||||
pub use file_ops::{
|
||||
edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput,
|
||||
GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload,
|
||||
WriteFileOutput,
|
||||
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
|
||||
grep_search_in_workspace, read_file, read_file_in_workspace, write_file,
|
||||
write_file_in_workspace, EditFileOutput, GlobSearchOutput, GrepSearchInput, GrepSearchOutput,
|
||||
ReadFileOutput, StructuredPatchHunk, TextFilePayload, WriteFileOutput,
|
||||
};
|
||||
pub use git_context::{GitCommitEntry, GitContext};
|
||||
pub use hooks::{
|
||||
@@ -131,18 +139,26 @@ pub use policy_engine::{
|
||||
PolicyEngine, PolicyRule, ReconcileReason, ReviewStatus,
|
||||
};
|
||||
pub use prompt::{
|
||||
load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError,
|
||||
SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
load_system_prompt, prepend_bullets, ContextFile, ModelFamilyIdentity, ProjectContext,
|
||||
PromptBuildError, SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
};
|
||||
pub use recovery_recipes::{
|
||||
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryContext,
|
||||
RecoveryEvent, RecoveryRecipe, RecoveryResult, RecoveryStep,
|
||||
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryAttemptState,
|
||||
RecoveryAttemptType, RecoveryCommandResult, RecoveryContext, RecoveryEvent,
|
||||
RecoveryLedgerEntry, RecoveryRecipe, RecoveryResult, RecoveryStatusReport, RecoveryStep,
|
||||
};
|
||||
pub use remote::{
|
||||
inherited_upstream_proxy_env, no_proxy_list, read_token, upstream_proxy_ws_url,
|
||||
RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL,
|
||||
DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS,
|
||||
};
|
||||
pub use report_schema::{
|
||||
canonicalize_report, project_report, report_content_hash, report_schema_v1_registry,
|
||||
CanonicalReportV1, ClaimKind, ConsumerCapabilities, FieldDelta, FieldDeltaState,
|
||||
NegativeEvidence, NegativeFindingStatus, ProjectionProvenance, RedactionProvenance,
|
||||
ReportClaim, ReportConfidence, ReportIdentity, ReportProjectionV1, ReportSchemaField,
|
||||
ReportSchemaRegistry, SensitivityClass, DEFAULT_PROJECTION_POLICY_V1, REPORT_SCHEMA_V1,
|
||||
};
|
||||
pub use sandbox::{
|
||||
build_linux_sandbox_command, detect_container_environment, detect_container_environment_from,
|
||||
resolve_sandbox_status, resolve_sandbox_status_for_request, ContainerEnvironment,
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
||||
|
||||
pub type GreenLevel = u8;
|
||||
|
||||
const STALE_BRANCH_THRESHOLD: Duration = Duration::from_secs(60 * 60);
|
||||
const STALE_BRANCH_THRESHOLD: Duration = Duration::from_hours(1);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PolicyRule {
|
||||
@@ -58,7 +58,9 @@ impl PolicyCondition {
|
||||
Self::Or(conditions) => conditions
|
||||
.iter()
|
||||
.any(|condition| condition.matches(context)),
|
||||
Self::GreenAt { level } => context.green_level >= *level,
|
||||
Self::GreenAt { level } => {
|
||||
context.green_contract_satisfied && context.green_level >= *level
|
||||
}
|
||||
Self::StaleBranch => context.branch_freshness >= STALE_BRANCH_THRESHOLD,
|
||||
Self::StartupBlocked => context.blocker == LaneBlocker::Startup,
|
||||
Self::LaneCompleted => context.completed,
|
||||
@@ -134,6 +136,7 @@ pub enum DiffScope {
|
||||
pub struct LaneContext {
|
||||
pub lane_id: String,
|
||||
pub green_level: GreenLevel,
|
||||
pub green_contract_satisfied: bool,
|
||||
pub branch_freshness: Duration,
|
||||
pub blocker: LaneBlocker,
|
||||
pub review_status: ReviewStatus,
|
||||
@@ -156,6 +159,7 @@ impl LaneContext {
|
||||
Self {
|
||||
lane_id: lane_id.into(),
|
||||
green_level,
|
||||
green_contract_satisfied: false,
|
||||
branch_freshness,
|
||||
blocker,
|
||||
review_status,
|
||||
@@ -171,6 +175,7 @@ impl LaneContext {
|
||||
Self {
|
||||
lane_id: lane_id.into(),
|
||||
green_level: 0,
|
||||
green_contract_satisfied: false,
|
||||
branch_freshness: Duration::from_secs(0),
|
||||
blocker: LaneBlocker::None,
|
||||
review_status: ReviewStatus::Pending,
|
||||
@@ -179,6 +184,12 @@ impl LaneContext {
|
||||
reconciled: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_green_contract_satisfied(mut self, satisfied: bool) -> Self {
|
||||
self.green_contract_satisfied = satisfied;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -257,7 +268,8 @@ mod tests {
|
||||
ReviewStatus::Approved,
|
||||
DiffScope::Scoped,
|
||||
false,
|
||||
);
|
||||
)
|
||||
.with_green_contract_satisfied(true);
|
||||
|
||||
// when
|
||||
let actions = engine.evaluate(&context);
|
||||
@@ -266,6 +278,36 @@ mod tests {
|
||||
assert_eq!(actions, vec![PolicyAction::MergeToDev]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_rule_blocks_when_green_tests_lack_contract_provenance() {
|
||||
// given
|
||||
let engine = PolicyEngine::new(vec![PolicyRule::new(
|
||||
"merge-to-dev",
|
||||
PolicyCondition::And(vec![
|
||||
PolicyCondition::GreenAt { level: 2 },
|
||||
PolicyCondition::ScopedDiff,
|
||||
PolicyCondition::ReviewPassed,
|
||||
]),
|
||||
PolicyAction::MergeToDev,
|
||||
20,
|
||||
)]);
|
||||
let context = LaneContext::new(
|
||||
"lane-7",
|
||||
3,
|
||||
Duration::from_secs(5),
|
||||
LaneBlocker::None,
|
||||
ReviewStatus::Approved,
|
||||
DiffScope::Scoped,
|
||||
false,
|
||||
);
|
||||
|
||||
// when
|
||||
let actions = engine.evaluate(&context);
|
||||
|
||||
// then
|
||||
assert!(actions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_branch_rule_fires_at_threshold() {
|
||||
// given
|
||||
@@ -468,7 +510,8 @@ mod tests {
|
||||
ReviewStatus::Pending,
|
||||
DiffScope::Full,
|
||||
false,
|
||||
);
|
||||
)
|
||||
.with_green_contract_satisfied(true);
|
||||
|
||||
// when
|
||||
let actions = engine.evaluate(&context);
|
||||
|
||||
@@ -43,6 +43,24 @@ pub const FRONTIER_MODEL_NAME: &str = "Claude Opus 4.6";
|
||||
const MAX_INSTRUCTION_FILE_CHARS: usize = 4_000;
|
||||
const MAX_TOTAL_INSTRUCTION_CHARS: usize = 12_000;
|
||||
|
||||
/// Neutral identity for the model family line in generated prompts.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum ModelFamilyIdentity {
|
||||
#[default]
|
||||
Claude,
|
||||
Generic,
|
||||
}
|
||||
|
||||
impl ModelFamilyIdentity {
|
||||
#[must_use]
|
||||
pub const fn family_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Claude => FRONTIER_MODEL_NAME,
|
||||
Self::Generic => "an AI assistant",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Contents of an instruction file included in prompt construction.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContextFile {
|
||||
@@ -97,6 +115,7 @@ pub struct SystemPromptBuilder {
|
||||
output_style_prompt: Option<String>,
|
||||
os_name: Option<String>,
|
||||
os_version: Option<String>,
|
||||
model_family: Option<ModelFamilyIdentity>,
|
||||
append_sections: Vec<String>,
|
||||
project_context: Option<ProjectContext>,
|
||||
config: Option<RuntimeConfig>,
|
||||
@@ -122,6 +141,12 @@ impl SystemPromptBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_model_family(mut self, model_family: ModelFamilyIdentity) -> Self {
|
||||
self.model_family = Some(model_family);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_project_context(mut self, project_context: ProjectContext) -> Self {
|
||||
self.project_context = Some(project_context);
|
||||
@@ -179,9 +204,10 @@ impl SystemPromptBuilder {
|
||||
|| "unknown".to_string(),
|
||||
|context| context.current_date.clone(),
|
||||
);
|
||||
let identity = self.model_family.unwrap_or_default();
|
||||
let mut lines = vec!["# Environment context".to_string()];
|
||||
lines.extend(prepend_bullets(vec![
|
||||
format!("Model family: {FRONTIER_MODEL_NAME}"),
|
||||
format!("Model family: {}", identity.family_label()),
|
||||
format!("Working directory: {cwd}"),
|
||||
format!("Date: {date}"),
|
||||
format!(
|
||||
@@ -434,12 +460,14 @@ pub fn load_system_prompt(
|
||||
current_date: impl Into<String>,
|
||||
os_name: impl Into<String>,
|
||||
os_version: impl Into<String>,
|
||||
model_family: ModelFamilyIdentity,
|
||||
) -> Result<Vec<String>, PromptBuildError> {
|
||||
let cwd = cwd.into();
|
||||
let project_context = ProjectContext::discover_with_git(&cwd, current_date.into())?;
|
||||
let config = ConfigLoader::default_for(&cwd).load()?;
|
||||
Ok(SystemPromptBuilder::new()
|
||||
.with_os(os_name, os_version)
|
||||
.with_model_family(model_family)
|
||||
.with_project_context(project_context)
|
||||
.with_runtime_config(config)
|
||||
.build())
|
||||
@@ -522,7 +550,8 @@ mod tests {
|
||||
use super::{
|
||||
collapse_blank_lines, display_context_path, normalize_instruction_content,
|
||||
render_instruction_content, render_instruction_files, truncate_instruction_content,
|
||||
ContextFile, ProjectContext, SystemPromptBuilder, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
ContextFile, ModelFamilyIdentity, ProjectContext, SystemPromptBuilder,
|
||||
SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
};
|
||||
use crate::config::ConfigLoader;
|
||||
use std::fs;
|
||||
@@ -804,13 +833,19 @@ mod tests {
|
||||
std::env::set_var("HOME", &root);
|
||||
std::env::set_var("CLAW_CONFIG_HOME", root.join("missing-home"));
|
||||
std::env::set_current_dir(&root).expect("change cwd");
|
||||
let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8")
|
||||
.expect("system prompt should load")
|
||||
.join(
|
||||
"
|
||||
let prompt = super::load_system_prompt(
|
||||
&root,
|
||||
"2026-03-31",
|
||||
"linux",
|
||||
"6.8",
|
||||
ModelFamilyIdentity::Claude,
|
||||
)
|
||||
.expect("system prompt should load")
|
||||
.join(
|
||||
"
|
||||
|
||||
",
|
||||
);
|
||||
);
|
||||
std::env::set_current_dir(previous).expect("restore cwd");
|
||||
if let Some(value) = original_home {
|
||||
std::env::set_var("HOME", value);
|
||||
@@ -828,6 +863,50 @@ mod tests {
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_default_claude_model_family_identity() {
|
||||
// given: a prompt builder without an explicit model family override
|
||||
let project_context = ProjectContext {
|
||||
cwd: PathBuf::from("/tmp/project"),
|
||||
current_date: "2026-03-31".to_string(),
|
||||
..ProjectContext::default()
|
||||
};
|
||||
|
||||
// when: rendering the system prompt environment section
|
||||
let prompt = SystemPromptBuilder::new()
|
||||
.with_os("linux", "6.8")
|
||||
.with_project_context(project_context)
|
||||
.render();
|
||||
|
||||
// then: the Claude model family label is preserved by default
|
||||
assert!(prompt.contains("Model family: Claude Opus 4.6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_generic_model_family_identity_without_claude_label() {
|
||||
// given: a prompt builder with generic model family identity
|
||||
let project_context = ProjectContext {
|
||||
cwd: PathBuf::from("/tmp/project"),
|
||||
current_date: "2026-03-31".to_string(),
|
||||
..ProjectContext::default()
|
||||
};
|
||||
|
||||
// when: rendering the system prompt environment section
|
||||
let prompt = SystemPromptBuilder::new()
|
||||
.with_os("linux", "6.8")
|
||||
.with_model_family(ModelFamilyIdentity::Generic)
|
||||
.with_project_context(project_context)
|
||||
.render();
|
||||
let model_family_line = prompt
|
||||
.lines()
|
||||
.find(|line| line.contains("Model family:"))
|
||||
.expect("model family line should render");
|
||||
|
||||
// then: the model family line is neutral and excludes Claude Opus 4.6
|
||||
assert_eq!(model_family_line, " - Model family: an AI assistant");
|
||||
assert!(!model_family_line.contains("Claude Opus 4.6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_claude_code_style_sections_with_project_context() {
|
||||
let root = temp_dir();
|
||||
|
||||
@@ -45,7 +45,9 @@ impl FailureScenario {
|
||||
#[must_use]
|
||||
pub fn from_worker_failure_kind(kind: WorkerFailureKind) -> Self {
|
||||
match kind {
|
||||
WorkerFailureKind::TrustGate => Self::TrustPromptUnresolved,
|
||||
WorkerFailureKind::TrustGate | WorkerFailureKind::ToolPermissionGate => {
|
||||
Self::TrustPromptUnresolved
|
||||
}
|
||||
WorkerFailureKind::PromptDelivery => Self::PromptMisdelivery,
|
||||
WorkerFailureKind::Protocol => Self::McpHandshakeFailure,
|
||||
WorkerFailureKind::Provider | WorkerFailureKind::StartupNoEvidence => {
|
||||
@@ -119,6 +121,21 @@ pub enum RecoveryResult {
|
||||
},
|
||||
}
|
||||
|
||||
/// Type of recovery execution represented in the ledger.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RecoveryAttemptType {
|
||||
Automatic,
|
||||
}
|
||||
|
||||
/// Result for one executable recovery command/step.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RecoveryCommandResult {
|
||||
pub command: RecoveryStep,
|
||||
pub status: RecoveryAttemptState,
|
||||
pub result: String,
|
||||
}
|
||||
|
||||
/// Structured event emitted during recovery.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -133,14 +150,59 @@ pub enum RecoveryEvent {
|
||||
Escalated,
|
||||
}
|
||||
|
||||
/// Machine-readable recovery progress for one failure scenario.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RecoveryLedgerEntry {
|
||||
pub recipe_id: String,
|
||||
pub attempt_type: RecoveryAttemptType,
|
||||
pub trigger: FailureScenario,
|
||||
pub attempt_count: u32,
|
||||
pub retry_limit: u32,
|
||||
pub attempts_remaining: u32,
|
||||
pub state: RecoveryAttemptState,
|
||||
pub started_at: Option<String>,
|
||||
pub finished_at: Option<String>,
|
||||
pub command_results: Vec<RecoveryCommandResult>,
|
||||
pub result: Option<RecoveryResult>,
|
||||
pub last_failure_summary: Option<String>,
|
||||
pub escalation_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Current state of a recovery recipe attempt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RecoveryAttemptState {
|
||||
Queued,
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed,
|
||||
Exhausted,
|
||||
}
|
||||
|
||||
/// Machine-readable status projection for callers that need to
|
||||
/// distinguish an untouched scenario from an exhausted recovery.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RecoveryStatusReport {
|
||||
pub scenario: FailureScenario,
|
||||
pub attempted: bool,
|
||||
pub state: Option<RecoveryAttemptState>,
|
||||
pub attempt_count: u32,
|
||||
pub retry_limit: Option<u32>,
|
||||
pub attempts_remaining: Option<u32>,
|
||||
pub escalation_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Minimal context for tracking recovery state and emitting events.
|
||||
///
|
||||
/// Holds per-scenario attempt counts, a structured event log, and an
|
||||
/// optional simulation knob for controlling step outcomes during tests.
|
||||
/// Holds per-scenario attempt counts, a structured event log, a recovery
|
||||
/// attempt ledger, and an optional simulation knob for controlling step
|
||||
/// outcomes during tests.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RecoveryContext {
|
||||
attempts: HashMap<FailureScenario, u32>,
|
||||
events: Vec<RecoveryEvent>,
|
||||
ledger: HashMap<FailureScenario, RecoveryLedgerEntry>,
|
||||
clock_tick: u64,
|
||||
/// Optional step index at which simulated execution fails.
|
||||
/// `None` means all steps succeed.
|
||||
fail_at_step: Option<usize>,
|
||||
@@ -170,6 +232,51 @@ impl RecoveryContext {
|
||||
pub fn attempt_count(&self, scenario: &FailureScenario) -> u32 {
|
||||
self.attempts.get(scenario).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Returns the machine-readable recovery ledger entry for a scenario.
|
||||
#[must_use]
|
||||
pub fn ledger_entry(&self, scenario: &FailureScenario) -> Option<&RecoveryLedgerEntry> {
|
||||
self.ledger.get(scenario)
|
||||
}
|
||||
|
||||
/// Returns all recovery ledger entries currently tracked by this context.
|
||||
#[must_use]
|
||||
pub fn ledger_entries(&self) -> Vec<&RecoveryLedgerEntry> {
|
||||
let mut entries: Vec<_> = self.ledger.values().collect();
|
||||
entries.sort_by(|left, right| left.recipe_id.cmp(&right.recipe_id));
|
||||
entries
|
||||
}
|
||||
|
||||
/// Returns a compact machine-readable recovery status for a scenario,
|
||||
/// including `attempted = false` when no ledger entry exists yet.
|
||||
#[must_use]
|
||||
pub fn status_report(&self, scenario: &FailureScenario) -> RecoveryStatusReport {
|
||||
self.ledger_entry(scenario).map_or(
|
||||
RecoveryStatusReport {
|
||||
scenario: *scenario,
|
||||
attempted: false,
|
||||
state: None,
|
||||
attempt_count: 0,
|
||||
retry_limit: None,
|
||||
attempts_remaining: None,
|
||||
escalation_reason: None,
|
||||
},
|
||||
|entry| RecoveryStatusReport {
|
||||
scenario: *scenario,
|
||||
attempted: entry.attempt_count > 0,
|
||||
state: Some(entry.state),
|
||||
attempt_count: entry.attempt_count,
|
||||
retry_limit: Some(entry.retry_limit),
|
||||
attempts_remaining: Some(entry.attempts_remaining),
|
||||
escalation_reason: entry.escalation_reason.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn next_timestamp(&mut self) -> String {
|
||||
self.clock_tick += 1;
|
||||
format!("recovery-ledger-tick-{}", self.clock_tick)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the known recovery recipe for the given failure scenario.
|
||||
@@ -231,18 +338,51 @@ pub fn recipe_for(scenario: &FailureScenario) -> RecoveryRecipe {
|
||||
/// Looks up the recipe, enforces the one-attempt-before-escalation
|
||||
/// policy, simulates step execution (controlled by the context), and
|
||||
/// emits structured [`RecoveryEvent`]s for every attempt.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn attempt_recovery(scenario: &FailureScenario, ctx: &mut RecoveryContext) -> RecoveryResult {
|
||||
let recipe = recipe_for(scenario);
|
||||
let attempt_count = ctx.attempts.entry(*scenario).or_insert(0);
|
||||
let recipe_id = scenario.to_string();
|
||||
ctx.ledger
|
||||
.entry(*scenario)
|
||||
.or_insert_with(|| RecoveryLedgerEntry {
|
||||
recipe_id: recipe_id.clone(),
|
||||
attempt_type: RecoveryAttemptType::Automatic,
|
||||
trigger: *scenario,
|
||||
attempt_count: 0,
|
||||
retry_limit: recipe.max_attempts,
|
||||
attempts_remaining: recipe.max_attempts,
|
||||
state: RecoveryAttemptState::Queued,
|
||||
started_at: None,
|
||||
finished_at: None,
|
||||
command_results: Vec::new(),
|
||||
result: None,
|
||||
last_failure_summary: None,
|
||||
escalation_reason: None,
|
||||
});
|
||||
|
||||
let current_attempts = ctx.attempt_count(scenario);
|
||||
|
||||
// Enforce one automatic recovery attempt before escalation.
|
||||
if *attempt_count >= recipe.max_attempts {
|
||||
if current_attempts >= recipe.max_attempts {
|
||||
let result = RecoveryResult::EscalationRequired {
|
||||
reason: format!(
|
||||
"max recovery attempts ({}) exceeded for {}",
|
||||
recipe.max_attempts, scenario
|
||||
),
|
||||
};
|
||||
let finished_at = ctx.next_timestamp();
|
||||
if let Some(entry) = ctx.ledger.get_mut(scenario) {
|
||||
entry.attempt_count = current_attempts;
|
||||
entry.attempts_remaining = 0;
|
||||
entry.state = RecoveryAttemptState::Exhausted;
|
||||
entry.finished_at = Some(finished_at);
|
||||
entry.result = Some(result.clone());
|
||||
let RecoveryResult::EscalationRequired { reason } = &result else {
|
||||
unreachable!("exhaustion always produces escalation");
|
||||
};
|
||||
entry.last_failure_summary = Some(reason.clone());
|
||||
entry.escalation_reason = Some(reason.clone());
|
||||
}
|
||||
ctx.events.push(RecoveryEvent::RecoveryAttempted {
|
||||
scenario: *scenario,
|
||||
recipe,
|
||||
@@ -252,19 +392,44 @@ pub fn attempt_recovery(scenario: &FailureScenario, ctx: &mut RecoveryContext) -
|
||||
return result;
|
||||
}
|
||||
|
||||
*attempt_count += 1;
|
||||
let updated_attempts = ctx.attempts.entry(*scenario).or_insert(0);
|
||||
*updated_attempts += 1;
|
||||
let updated_attempts = *updated_attempts;
|
||||
let started_at = ctx.next_timestamp();
|
||||
if let Some(entry) = ctx.ledger.get_mut(scenario) {
|
||||
entry.attempt_count = updated_attempts;
|
||||
entry.attempts_remaining = recipe.max_attempts.saturating_sub(updated_attempts);
|
||||
entry.state = RecoveryAttemptState::Running;
|
||||
entry.started_at = Some(started_at);
|
||||
entry.finished_at = None;
|
||||
entry.command_results.clear();
|
||||
entry.result = None;
|
||||
entry.last_failure_summary = None;
|
||||
entry.escalation_reason = None;
|
||||
}
|
||||
|
||||
// Execute steps, honoring the optional fail_at_step simulation.
|
||||
let fail_index = ctx.fail_at_step;
|
||||
let mut executed = Vec::new();
|
||||
let mut command_results = Vec::new();
|
||||
let mut failed = false;
|
||||
|
||||
for (i, step) in recipe.steps.iter().enumerate() {
|
||||
if fail_index == Some(i) {
|
||||
command_results.push(RecoveryCommandResult {
|
||||
command: step.clone(),
|
||||
status: RecoveryAttemptState::Failed,
|
||||
result: format!("step {i} failed for {scenario}"),
|
||||
});
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
executed.push(step.clone());
|
||||
command_results.push(RecoveryCommandResult {
|
||||
command: step.clone(),
|
||||
status: RecoveryAttemptState::Succeeded,
|
||||
result: format!("step {i} succeeded for {scenario}"),
|
||||
});
|
||||
}
|
||||
|
||||
let result = if failed {
|
||||
@@ -286,6 +451,29 @@ pub fn attempt_recovery(scenario: &FailureScenario, ctx: &mut RecoveryContext) -
|
||||
};
|
||||
|
||||
// Emit the attempt as structured event data.
|
||||
let finished_at = ctx.next_timestamp();
|
||||
if let Some(entry) = ctx.ledger.get_mut(scenario) {
|
||||
entry.finished_at = Some(finished_at);
|
||||
entry.command_results = command_results;
|
||||
entry.result = Some(result.clone());
|
||||
match &result {
|
||||
RecoveryResult::Recovered { .. } => {
|
||||
entry.state = RecoveryAttemptState::Succeeded;
|
||||
}
|
||||
RecoveryResult::PartialRecovery { remaining, .. } => {
|
||||
entry.state = RecoveryAttemptState::Failed;
|
||||
entry.last_failure_summary = Some(format!(
|
||||
"{} step(s) remaining after partial recovery",
|
||||
remaining.len()
|
||||
));
|
||||
}
|
||||
RecoveryResult::EscalationRequired { reason } => {
|
||||
entry.state = RecoveryAttemptState::Exhausted;
|
||||
entry.last_failure_summary = Some(reason.clone());
|
||||
entry.escalation_reason = Some(reason.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.events.push(RecoveryEvent::RecoveryAttempted {
|
||||
scenario: *scenario,
|
||||
recipe,
|
||||
@@ -497,6 +685,126 @@ mod tests {
|
||||
assert_eq!(ctx.attempt_count(&FailureScenario::PromptMisdelivery), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_context_exposes_machine_readable_ledger() {
|
||||
// given
|
||||
let mut ctx = RecoveryContext::new();
|
||||
|
||||
// when
|
||||
let result = attempt_recovery(&FailureScenario::StaleBranch, &mut ctx);
|
||||
|
||||
// then
|
||||
assert_eq!(result, RecoveryResult::Recovered { steps_taken: 2 });
|
||||
let entry = ctx
|
||||
.ledger_entry(&FailureScenario::StaleBranch)
|
||||
.expect("stale branch ledger entry");
|
||||
assert_eq!(entry.recipe_id, "stale_branch");
|
||||
assert_eq!(entry.attempt_type, RecoveryAttemptType::Automatic);
|
||||
assert_eq!(entry.trigger, FailureScenario::StaleBranch);
|
||||
assert_eq!(entry.attempt_count, 1);
|
||||
assert_eq!(entry.retry_limit, 1);
|
||||
assert_eq!(entry.attempts_remaining, 0);
|
||||
assert_eq!(entry.state, RecoveryAttemptState::Succeeded);
|
||||
assert!(entry.started_at.is_some());
|
||||
assert!(entry.finished_at.is_some());
|
||||
assert_eq!(
|
||||
entry.result,
|
||||
Some(RecoveryResult::Recovered { steps_taken: 2 })
|
||||
);
|
||||
assert_eq!(entry.command_results.len(), 2);
|
||||
assert_eq!(entry.command_results[0].command, RecoveryStep::RebaseBranch);
|
||||
assert_eq!(
|
||||
entry.command_results[0].status,
|
||||
RecoveryAttemptState::Succeeded
|
||||
);
|
||||
assert_eq!(entry.last_failure_summary, None);
|
||||
assert_eq!(entry.escalation_reason, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_ledger_records_exhausted_escalation_reason() {
|
||||
// given
|
||||
let mut ctx = RecoveryContext::new();
|
||||
let scenario = FailureScenario::PromptMisdelivery;
|
||||
|
||||
// when
|
||||
let _ = attempt_recovery(&scenario, &mut ctx);
|
||||
let result = attempt_recovery(&scenario, &mut ctx);
|
||||
|
||||
// then
|
||||
assert!(matches!(result, RecoveryResult::EscalationRequired { .. }));
|
||||
let entry = ctx.ledger_entry(&scenario).expect("ledger entry");
|
||||
assert_eq!(entry.state, RecoveryAttemptState::Exhausted);
|
||||
assert_eq!(entry.attempt_count, 1);
|
||||
assert_eq!(entry.attempts_remaining, 0);
|
||||
assert!(matches!(
|
||||
entry.result,
|
||||
Some(RecoveryResult::EscalationRequired { .. })
|
||||
));
|
||||
assert!(entry
|
||||
.escalation_reason
|
||||
.as_deref()
|
||||
.expect("escalation reason")
|
||||
.contains("max recovery attempts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_status_report_distinguishes_not_attempted_from_exhausted() {
|
||||
// given
|
||||
let mut ctx = RecoveryContext::new();
|
||||
let scenario = FailureScenario::PromptMisdelivery;
|
||||
|
||||
// then — no ledger entry is not the same as exhausted.
|
||||
let not_attempted = ctx.status_report(&scenario);
|
||||
assert!(!not_attempted.attempted);
|
||||
assert_eq!(not_attempted.state, None);
|
||||
assert_eq!(not_attempted.attempt_count, 0);
|
||||
assert_eq!(not_attempted.retry_limit, None);
|
||||
|
||||
// when — one allowed attempt then one extra attempt.
|
||||
let _ = attempt_recovery(&scenario, &mut ctx);
|
||||
let _ = attempt_recovery(&scenario, &mut ctx);
|
||||
|
||||
// then
|
||||
let exhausted = ctx.status_report(&scenario);
|
||||
assert!(exhausted.attempted);
|
||||
assert_eq!(exhausted.state, Some(RecoveryAttemptState::Exhausted));
|
||||
assert_eq!(exhausted.attempt_count, 1);
|
||||
assert_eq!(exhausted.retry_limit, Some(1));
|
||||
assert_eq!(exhausted.attempts_remaining, Some(0));
|
||||
assert!(exhausted
|
||||
.escalation_reason
|
||||
.as_deref()
|
||||
.is_some_and(|reason| reason.contains("max recovery attempts")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_ledger_records_failed_command_result() {
|
||||
// given
|
||||
let mut ctx = RecoveryContext::new().with_fail_at_step(1);
|
||||
let scenario = FailureScenario::PartialPluginStartup;
|
||||
|
||||
// when
|
||||
let result = attempt_recovery(&scenario, &mut ctx);
|
||||
|
||||
// then
|
||||
assert!(matches!(result, RecoveryResult::PartialRecovery { .. }));
|
||||
let entry = ctx.ledger_entry(&scenario).expect("ledger entry");
|
||||
assert_eq!(entry.state, RecoveryAttemptState::Failed);
|
||||
assert_eq!(entry.command_results.len(), 2);
|
||||
assert_eq!(
|
||||
entry.command_results[0].status,
|
||||
RecoveryAttemptState::Succeeded
|
||||
);
|
||||
assert_eq!(
|
||||
entry.command_results[1].status,
|
||||
RecoveryAttemptState::Failed
|
||||
);
|
||||
assert!(entry.command_results[1]
|
||||
.result
|
||||
.contains("partial_plugin_startup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_branch_recipe_has_rebase_then_clean_build() {
|
||||
// given
|
||||
|
||||
552
rust/crates/runtime/src/report_schema.rs
Normal file
552
rust/crates/runtime/src/report_schema.rs
Normal file
@@ -0,0 +1,552 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub const REPORT_SCHEMA_V1: &str = "claw.report.v1";
|
||||
pub const DEFAULT_PROJECTION_POLICY_V1: &str = "claw.report.projection.v1";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ClaimKind {
|
||||
ObservedFact,
|
||||
Inference,
|
||||
Hypothesis,
|
||||
Recommendation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReportConfidence {
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SensitivityClass {
|
||||
Public,
|
||||
Internal,
|
||||
OperatorOnly,
|
||||
Secret,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FieldDeltaState {
|
||||
Changed,
|
||||
Unchanged,
|
||||
Cleared,
|
||||
CarriedForward,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NegativeFindingStatus {
|
||||
NotObservedInCheckedScope,
|
||||
UnknownNotChecked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReportClaim {
|
||||
pub id: String,
|
||||
pub kind: ClaimKind,
|
||||
pub text: String,
|
||||
pub confidence: ReportConfidence,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub evidence: Vec<String>,
|
||||
pub sensitivity: SensitivityClass,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NegativeEvidence {
|
||||
pub id: String,
|
||||
pub status: NegativeFindingStatus,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub checked_surfaces: Vec<String>,
|
||||
pub query: String,
|
||||
pub window: String,
|
||||
pub sensitivity: SensitivityClass,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FieldDelta {
|
||||
pub field: String,
|
||||
pub state: FieldDeltaState,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub previous_hash: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current_hash: Option<String>,
|
||||
pub attribution: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReportIdentity {
|
||||
pub report_id: String,
|
||||
pub content_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CanonicalReportV1 {
|
||||
pub schema_version: String,
|
||||
pub identity: ReportIdentity,
|
||||
pub generated_at: String,
|
||||
pub producer: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub claims: Vec<ReportClaim>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub negative_evidence: Vec<NegativeEvidence>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub field_deltas: Vec<FieldDelta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConsumerCapabilities {
|
||||
pub consumer: String,
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
pub schema_versions: BTreeSet<String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
pub field_families: BTreeSet<String>,
|
||||
pub max_sensitivity: SensitivityClass,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RedactionProvenance {
|
||||
pub field_path: String,
|
||||
pub reason: String,
|
||||
pub policy_id: String,
|
||||
pub original_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProjectionProvenance {
|
||||
pub policy_id: String,
|
||||
pub source_schema_version: String,
|
||||
pub source_report_id: String,
|
||||
pub source_content_hash: String,
|
||||
pub consumer: String,
|
||||
pub downgraded: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub omitted_field_families: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub redactions: Vec<RedactionProvenance>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReportProjectionV1 {
|
||||
pub schema_version: String,
|
||||
pub projection_id: String,
|
||||
pub view: String,
|
||||
pub provenance: ProjectionProvenance,
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReportSchemaField {
|
||||
pub id: String,
|
||||
pub description: String,
|
||||
pub required: bool,
|
||||
pub field_family: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReportSchemaRegistry {
|
||||
pub schema_version: String,
|
||||
pub compatibility: String,
|
||||
pub fields: Vec<ReportSchemaField>,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn report_schema_v1_registry() -> ReportSchemaRegistry {
|
||||
ReportSchemaRegistry {
|
||||
schema_version: REPORT_SCHEMA_V1.to_string(),
|
||||
compatibility: "additive fields are compatible; missing required fields are breaking"
|
||||
.to_string(),
|
||||
fields: vec![
|
||||
field(
|
||||
"identity.report_id",
|
||||
"stable canonical report identity",
|
||||
true,
|
||||
"identity",
|
||||
),
|
||||
field(
|
||||
"identity.content_hash",
|
||||
"hash of canonical payload excluding identity",
|
||||
true,
|
||||
"identity",
|
||||
),
|
||||
field(
|
||||
"claims[].kind",
|
||||
"fact/inference/hypothesis/recommendation label",
|
||||
true,
|
||||
"claims",
|
||||
),
|
||||
field(
|
||||
"claims[].confidence",
|
||||
"confidence bucket for the claim",
|
||||
true,
|
||||
"claims",
|
||||
),
|
||||
field(
|
||||
"claims[].evidence",
|
||||
"evidence ids supporting a claim",
|
||||
false,
|
||||
"claims",
|
||||
),
|
||||
field(
|
||||
"negative_evidence[]",
|
||||
"searched-and-not-found findings with checked scope",
|
||||
false,
|
||||
"negative_evidence",
|
||||
),
|
||||
field(
|
||||
"field_deltas[]",
|
||||
"field-level changed/unchanged/cleared/carried-forward attribution",
|
||||
false,
|
||||
"field_deltas",
|
||||
),
|
||||
field(
|
||||
"projection.provenance.redactions[]",
|
||||
"redaction policy provenance for projected fields",
|
||||
false,
|
||||
"projection",
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn canonicalize_report(mut report: CanonicalReportV1) -> CanonicalReportV1 {
|
||||
report.schema_version = REPORT_SCHEMA_V1.to_string();
|
||||
report.claims.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
report.negative_evidence.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
report.field_deltas.sort_by(|a, b| a.field.cmp(&b.field));
|
||||
let content_hash = report_content_hash(&report);
|
||||
if report.identity.report_id.is_empty() {
|
||||
report.identity.report_id = format!("report-{content_hash}");
|
||||
}
|
||||
report.identity.content_hash = content_hash;
|
||||
report
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn report_content_hash(report: &CanonicalReportV1) -> String {
|
||||
let mut hashable = report.clone();
|
||||
hashable.identity.report_id.clear();
|
||||
hashable.identity.content_hash.clear();
|
||||
stable_json_hash(&serde_json::to_value(hashable).expect("report should serialize"))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn project_report(
|
||||
report: &CanonicalReportV1,
|
||||
capabilities: &ConsumerCapabilities,
|
||||
view: impl Into<String>,
|
||||
) -> ReportProjectionV1 {
|
||||
let view = view.into();
|
||||
let supports_schema = capabilities.schema_versions.contains(REPORT_SCHEMA_V1);
|
||||
let mut omitted_field_families = Vec::new();
|
||||
let mut redactions = Vec::new();
|
||||
let mut payload = serde_json::Map::new();
|
||||
|
||||
payload.insert(
|
||||
"identity".to_string(),
|
||||
serde_json::to_value(&report.identity).expect("identity serializes"),
|
||||
);
|
||||
payload.insert(
|
||||
"generated_at".to_string(),
|
||||
Value::String(report.generated_at.clone()),
|
||||
);
|
||||
payload.insert(
|
||||
"producer".to_string(),
|
||||
Value::String(report.producer.clone()),
|
||||
);
|
||||
|
||||
if supports_family(capabilities, "claims") {
|
||||
let claims = report
|
||||
.claims
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, claim)| redact_claim(index, claim, capabilities, &mut redactions))
|
||||
.collect::<Vec<_>>();
|
||||
payload.insert("claims".to_string(), Value::Array(claims));
|
||||
} else {
|
||||
omitted_field_families.push("claims".to_string());
|
||||
}
|
||||
|
||||
if supports_family(capabilities, "negative_evidence") {
|
||||
payload.insert(
|
||||
"negative_evidence".to_string(),
|
||||
serde_json::to_value(&report.negative_evidence).expect("negative evidence serializes"),
|
||||
);
|
||||
} else {
|
||||
omitted_field_families.push("negative_evidence".to_string());
|
||||
}
|
||||
|
||||
if supports_family(capabilities, "field_deltas") {
|
||||
payload.insert(
|
||||
"field_deltas".to_string(),
|
||||
serde_json::to_value(&report.field_deltas).expect("field deltas serialize"),
|
||||
);
|
||||
} else {
|
||||
omitted_field_families.push("field_deltas".to_string());
|
||||
}
|
||||
|
||||
let downgraded =
|
||||
!supports_schema || !omitted_field_families.is_empty() || !redactions.is_empty();
|
||||
let provenance = ProjectionProvenance {
|
||||
policy_id: DEFAULT_PROJECTION_POLICY_V1.to_string(),
|
||||
source_schema_version: report.schema_version.clone(),
|
||||
source_report_id: report.identity.report_id.clone(),
|
||||
source_content_hash: report.identity.content_hash.clone(),
|
||||
consumer: capabilities.consumer.clone(),
|
||||
downgraded,
|
||||
omitted_field_families,
|
||||
redactions,
|
||||
};
|
||||
let mut projection = ReportProjectionV1 {
|
||||
schema_version: REPORT_SCHEMA_V1.to_string(),
|
||||
projection_id: String::new(),
|
||||
view,
|
||||
provenance,
|
||||
payload: Value::Object(payload),
|
||||
};
|
||||
projection.projection_id = stable_json_hash(&serde_json::json!({
|
||||
"view": projection.view,
|
||||
"provenance": projection.provenance,
|
||||
"payload": projection.payload,
|
||||
}));
|
||||
projection
|
||||
}
|
||||
|
||||
fn field(id: &str, description: &str, required: bool, field_family: &str) -> ReportSchemaField {
|
||||
ReportSchemaField {
|
||||
id: id.to_string(),
|
||||
description: description.to_string(),
|
||||
required,
|
||||
field_family: field_family.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_family(capabilities: &ConsumerCapabilities, family: &str) -> bool {
|
||||
capabilities.field_families.is_empty() || capabilities.field_families.contains(family)
|
||||
}
|
||||
|
||||
fn redact_claim(
|
||||
index: usize,
|
||||
claim: &ReportClaim,
|
||||
capabilities: &ConsumerCapabilities,
|
||||
redactions: &mut Vec<RedactionProvenance>,
|
||||
) -> Option<Value> {
|
||||
if claim.sensitivity <= capabilities.max_sensitivity {
|
||||
return Some(serde_json::to_value(claim).expect("claim serializes"));
|
||||
}
|
||||
if claim.sensitivity == SensitivityClass::Secret {
|
||||
redactions.push(RedactionProvenance {
|
||||
field_path: format!("claims[{index}]"),
|
||||
reason: "omitted: sensitivity exceeds consumer policy".to_string(),
|
||||
policy_id: DEFAULT_PROJECTION_POLICY_V1.to_string(),
|
||||
original_hash: stable_json_hash(
|
||||
&serde_json::to_value(claim).expect("claim serializes"),
|
||||
),
|
||||
});
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut redacted = claim.clone();
|
||||
let original_hash = stable_json_hash(&serde_json::to_value(claim).expect("claim serializes"));
|
||||
redacted.text = "<redacted>".to_string();
|
||||
redacted.evidence.clear();
|
||||
redactions.push(RedactionProvenance {
|
||||
field_path: format!("claims[{index}].text"),
|
||||
reason: "transformed: sensitivity exceeds consumer policy".to_string(),
|
||||
policy_id: DEFAULT_PROJECTION_POLICY_V1.to_string(),
|
||||
original_hash,
|
||||
});
|
||||
Some(serde_json::to_value(redacted).expect("redacted claim serializes"))
|
||||
}
|
||||
|
||||
fn stable_json_hash(value: &Value) -> String {
|
||||
let normalized = normalize_json(value);
|
||||
let bytes = serde_json::to_vec(&normalized).expect("normalized json should serialize");
|
||||
let digest = Sha256::digest(bytes);
|
||||
let mut hash = String::with_capacity(16);
|
||||
for byte in &digest[..8] {
|
||||
use std::fmt::Write as _;
|
||||
write!(&mut hash, "{byte:02x}").expect("writing to String should not fail");
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
fn normalize_json(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Array(values) => Value::Array(values.iter().map(normalize_json).collect()),
|
||||
Value::Object(map) => {
|
||||
let sorted = map
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), normalize_json(value)))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
serde_json::to_value(sorted).expect("sorted map should serialize")
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
canonicalize_report, project_report, report_schema_v1_registry, CanonicalReportV1,
|
||||
ClaimKind, ConsumerCapabilities, FieldDelta, FieldDeltaState, NegativeEvidence,
|
||||
NegativeFindingStatus, ReportClaim, ReportConfidence, ReportIdentity, SensitivityClass,
|
||||
REPORT_SCHEMA_V1,
|
||||
};
|
||||
|
||||
fn fixture_report() -> CanonicalReportV1 {
|
||||
canonicalize_report(CanonicalReportV1 {
|
||||
schema_version: String::new(),
|
||||
identity: ReportIdentity {
|
||||
report_id: String::new(),
|
||||
content_hash: String::new(),
|
||||
},
|
||||
generated_at: "2026-05-14T00:00:00Z".to_string(),
|
||||
producer: "worker-1".to_string(),
|
||||
claims: vec![
|
||||
ReportClaim {
|
||||
id: "claim-secret".to_string(),
|
||||
kind: ClaimKind::ObservedFact,
|
||||
text: "secret token appeared in logs".to_string(),
|
||||
confidence: ReportConfidence::High,
|
||||
evidence: vec!["log:secret".to_string()],
|
||||
sensitivity: SensitivityClass::Secret,
|
||||
},
|
||||
ReportClaim {
|
||||
id: "claim-hypothesis".to_string(),
|
||||
kind: ClaimKind::Hypothesis,
|
||||
text: "transport restart likely caused the retry".to_string(),
|
||||
confidence: ReportConfidence::Medium,
|
||||
evidence: vec!["event:transport".to_string()],
|
||||
sensitivity: SensitivityClass::Internal,
|
||||
},
|
||||
ReportClaim {
|
||||
id: "claim-fact".to_string(),
|
||||
kind: ClaimKind::ObservedFact,
|
||||
text: "lane finished once".to_string(),
|
||||
confidence: ReportConfidence::High,
|
||||
evidence: vec!["event:lane.finished".to_string()],
|
||||
sensitivity: SensitivityClass::Public,
|
||||
},
|
||||
],
|
||||
negative_evidence: vec![NegativeEvidence {
|
||||
id: "neg-blocker".to_string(),
|
||||
status: NegativeFindingStatus::NotObservedInCheckedScope,
|
||||
checked_surfaces: vec!["lane_events".to_string(), "worker_status".to_string()],
|
||||
query: "current blocker".to_string(),
|
||||
window: "2026-05-14T00:00:00Z/2026-05-14T00:05:00Z".to_string(),
|
||||
sensitivity: SensitivityClass::Public,
|
||||
}],
|
||||
field_deltas: vec![FieldDelta {
|
||||
field: "blocker".to_string(),
|
||||
state: FieldDeltaState::Cleared,
|
||||
previous_hash: Some("prev123".to_string()),
|
||||
current_hash: None,
|
||||
attribution: "lane.failed reconciled to lane.finished".to_string(),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(families: &[&str], max_sensitivity: SensitivityClass) -> ConsumerCapabilities {
|
||||
ConsumerCapabilities {
|
||||
consumer: "clawhip".to_string(),
|
||||
schema_versions: [REPORT_SCHEMA_V1.to_string()].into_iter().collect(),
|
||||
field_families: families
|
||||
.iter()
|
||||
.map(|family| (*family).to_string())
|
||||
.collect(),
|
||||
max_sensitivity,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_schema_registry_is_self_describing() {
|
||||
let registry = report_schema_v1_registry();
|
||||
assert_eq!(registry.schema_version, REPORT_SCHEMA_V1);
|
||||
assert!(registry
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| field.id == "claims[].kind"));
|
||||
assert!(registry
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| field.id == "negative_evidence[]"));
|
||||
assert!(registry
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| field.id == "projection.provenance.redactions[]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_report_labels_claims_negative_evidence_and_deltas() {
|
||||
let report = fixture_report();
|
||||
assert_eq!(report.schema_version, REPORT_SCHEMA_V1);
|
||||
assert!(report.identity.report_id.starts_with("report-"));
|
||||
assert_eq!(report.identity.content_hash.len(), 16);
|
||||
assert_eq!(report.claims[0].id, "claim-fact");
|
||||
assert_eq!(report.claims[1].kind, ClaimKind::Hypothesis);
|
||||
assert_eq!(report.claims[1].confidence, ReportConfidence::Medium);
|
||||
assert_eq!(
|
||||
report.negative_evidence[0].status,
|
||||
NegativeFindingStatus::NotObservedInCheckedScope
|
||||
);
|
||||
assert_eq!(report.field_deltas[0].state, FieldDeltaState::Cleared);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projections_are_deterministic_and_record_redaction_provenance() {
|
||||
let report = fixture_report();
|
||||
let capabilities = capabilities(
|
||||
&["claims", "negative_evidence", "field_deltas"],
|
||||
SensitivityClass::Public,
|
||||
);
|
||||
|
||||
let first = project_report(&report, &capabilities, "delta_brief");
|
||||
let second = project_report(&report, &capabilities, "delta_brief");
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first.provenance.source_report_id, report.identity.report_id);
|
||||
assert_eq!(
|
||||
first.provenance.source_content_hash,
|
||||
report.identity.content_hash
|
||||
);
|
||||
assert!(first.provenance.downgraded);
|
||||
assert_eq!(first.provenance.redactions.len(), 2);
|
||||
assert!(first
|
||||
.provenance
|
||||
.redactions
|
||||
.iter()
|
||||
.any(|redaction| redaction.field_path == "claims[1].text"));
|
||||
assert!(first
|
||||
.provenance
|
||||
.redactions
|
||||
.iter()
|
||||
.any(|redaction| redaction.field_path == "claims[2]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_negotiation_omits_unsupported_field_families() {
|
||||
let report = fixture_report();
|
||||
let capabilities = capabilities(&["claims"], SensitivityClass::Internal);
|
||||
let projection = project_report(&report, &capabilities, "legacy_clawhip");
|
||||
|
||||
assert!(projection.provenance.downgraded);
|
||||
assert_eq!(
|
||||
projection.provenance.omitted_field_families,
|
||||
vec!["negative_evidence".to_string(), "field_deltas".to_string()]
|
||||
);
|
||||
assert!(projection.payload.get("claims").is_some());
|
||||
assert!(projection.payload.get("negative_evidence").is_none());
|
||||
assert!(projection.payload.get("field_deltas").is_none());
|
||||
}
|
||||
}
|
||||
@@ -298,8 +298,7 @@ fn unshare_user_namespace_works() -> bool {
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
.is_ok_and(|status| status.success())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ pub enum ContentBlock {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
Thinking {
|
||||
thinking: String,
|
||||
signature: Option<String>,
|
||||
},
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -737,6 +741,22 @@ impl ContentBlock {
|
||||
object.insert("type".to_string(), JsonValue::String("text".to_string()));
|
||||
object.insert("text".to_string(), JsonValue::String(text.clone()));
|
||||
}
|
||||
Self::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
object.insert(
|
||||
"type".to_string(),
|
||||
JsonValue::String("thinking".to_string()),
|
||||
);
|
||||
object.insert("thinking".to_string(), JsonValue::String(thinking.clone()));
|
||||
if let Some(signature) = signature {
|
||||
object.insert(
|
||||
"signature".to_string(),
|
||||
JsonValue::String(signature.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
Self::ToolUse { id, name, input } => {
|
||||
object.insert(
|
||||
"type".to_string(),
|
||||
@@ -783,6 +803,13 @@ impl ContentBlock {
|
||||
"text" => Ok(Self::Text {
|
||||
text: required_string(object, "text")?,
|
||||
}),
|
||||
"thinking" => Ok(Self::Thinking {
|
||||
thinking: required_string(object, "thinking")?,
|
||||
signature: object
|
||||
.get("signature")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(String::from),
|
||||
}),
|
||||
"tool_use" => Ok(Self::ToolUse {
|
||||
id: required_string(object, "id")?,
|
||||
name: required_string(object, "name")?,
|
||||
@@ -1208,6 +1235,36 @@ mod tests {
|
||||
assert_eq!(restored.session_id, session.session_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_assistant_thinking_block_round_trip_through_jsonl() {
|
||||
// given
|
||||
let mut session = Session::new();
|
||||
session
|
||||
.push_message(ConversationMessage::assistant(vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "trace the path through session persistence".to_string(),
|
||||
signature: Some("sig-123".to_string()),
|
||||
},
|
||||
]))
|
||||
.expect("thinking block should append");
|
||||
let path = temp_session_path("thinking-jsonl");
|
||||
|
||||
// when
|
||||
session.save_to_path(&path).expect("session should save");
|
||||
let restored = Session::load_from_path(&path).expect("session should load");
|
||||
fs::remove_file(&path).expect("temp file should be removable");
|
||||
|
||||
// then
|
||||
assert_eq!(restored, session);
|
||||
assert_eq!(
|
||||
restored.messages[0].blocks[0],
|
||||
ContentBlock::Thinking {
|
||||
thinking: "trace the path through session persistence".to_string(),
|
||||
signature: Some("sig-123".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_legacy_session_json_object() {
|
||||
let path = temp_session_path("legacy");
|
||||
|
||||
@@ -31,14 +31,19 @@ impl SessionStore {
|
||||
/// The on-disk layout becomes `<cwd>/.claw/sessions/<workspace_hash>/`.
|
||||
pub fn from_cwd(cwd: impl AsRef<Path>) -> Result<Self, SessionControlError> {
|
||||
let cwd = cwd.as_ref();
|
||||
let sessions_root = cwd
|
||||
// #151: canonicalize so equivalent paths (symlinks, relative vs
|
||||
// absolute, /tmp vs /private/tmp on macOS) produce the same
|
||||
// workspace_fingerprint. Falls back to the raw path if canonicalize
|
||||
// fails (e.g. the directory doesn't exist yet).
|
||||
let canonical_cwd = fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
|
||||
let sessions_root = canonical_cwd
|
||||
.join(".claw")
|
||||
.join("sessions")
|
||||
.join(workspace_fingerprint(cwd));
|
||||
.join(workspace_fingerprint(&canonical_cwd));
|
||||
fs::create_dir_all(&sessions_root)?;
|
||||
Ok(Self {
|
||||
sessions_root,
|
||||
workspace_root: cwd.to_path_buf(),
|
||||
workspace_root: canonical_cwd,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,14 +56,18 @@ impl SessionStore {
|
||||
workspace_root: impl AsRef<Path>,
|
||||
) -> Result<Self, SessionControlError> {
|
||||
let workspace_root = workspace_root.as_ref();
|
||||
// #151: canonicalize workspace_root for consistent fingerprinting
|
||||
// across equivalent path representations.
|
||||
let canonical_workspace =
|
||||
fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
|
||||
let sessions_root = data_dir
|
||||
.as_ref()
|
||||
.join("sessions")
|
||||
.join(workspace_fingerprint(workspace_root));
|
||||
.join(workspace_fingerprint(&canonical_workspace));
|
||||
fs::create_dir_all(&sessions_root)?;
|
||||
Ok(Self {
|
||||
sessions_root,
|
||||
workspace_root: workspace_root.to_path_buf(),
|
||||
workspace_root: canonical_workspace,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -103,7 +112,7 @@ impl SessionStore {
|
||||
candidate
|
||||
} else if looks_like_path {
|
||||
return Err(SessionControlError::Format(
|
||||
format_missing_session_reference(reference),
|
||||
format_missing_session_reference(reference, &self.sessions_root),
|
||||
));
|
||||
} else {
|
||||
self.resolve_managed_path(reference)?
|
||||
@@ -134,7 +143,7 @@ impl SessionStore {
|
||||
}
|
||||
}
|
||||
Err(SessionControlError::Format(
|
||||
format_missing_session_reference(session_id),
|
||||
format_missing_session_reference(session_id, &self.sessions_root),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -149,10 +158,9 @@ impl SessionStore {
|
||||
}
|
||||
|
||||
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
|
||||
self.list_sessions()?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| SessionControlError::Format(format_no_managed_sessions()))
|
||||
self.list_sessions()?.into_iter().next().ok_or_else(|| {
|
||||
SessionControlError::Format(format_no_managed_sessions(&self.sessions_root))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_session(
|
||||
@@ -513,15 +521,25 @@ fn session_id_from_path(path: &Path) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn format_missing_session_reference(reference: &str) -> String {
|
||||
fn format_missing_session_reference(reference: &str, sessions_root: &Path) -> String {
|
||||
// #80: show the actual workspace-fingerprint directory instead of lying about .claw/sessions/
|
||||
let fingerprint_dir = sessions_root
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("<unknown>");
|
||||
format!(
|
||||
"session not found: {reference}\nHint: managed sessions live in .claw/sessions/. Try `{LATEST_SESSION_REFERENCE}` for the most recent session or `/session list` in the REPL."
|
||||
"session not found: {reference}\nHint: managed sessions live in .claw/sessions/{fingerprint_dir}/ (workspace-specific partition).\nTry `{LATEST_SESSION_REFERENCE}` for the most recent session or `/session list` in the REPL."
|
||||
)
|
||||
}
|
||||
|
||||
fn format_no_managed_sessions() -> String {
|
||||
fn format_no_managed_sessions(sessions_root: &Path) -> String {
|
||||
// #80: show the actual workspace-fingerprint directory instead of lying about .claw/sessions/
|
||||
let fingerprint_dir = sessions_root
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("<unknown>");
|
||||
format!(
|
||||
"no managed sessions found in .claw/sessions/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`."
|
||||
"no managed sessions found in .claw/sessions/{fingerprint_dir}/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`.\nNote: claw partitions sessions per workspace fingerprint; sessions from other CWDs are invisible."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -744,6 +762,40 @@ mod tests {
|
||||
assert_eq!(fp_a1.len(), 16, "fingerprint must be a 16-char hex string");
|
||||
}
|
||||
|
||||
/// #151 regression: equivalent paths (e.g. `/tmp/foo` vs `/private/tmp/foo`
|
||||
/// on macOS where `/tmp` is a symlink to `/private/tmp`) must resolve to
|
||||
/// the same session store. Previously they diverged because
|
||||
/// `workspace_fingerprint()` hashed the raw path string. Now
|
||||
/// `SessionStore::from_cwd()` canonicalizes first.
|
||||
#[test]
|
||||
fn session_store_from_cwd_canonicalizes_equivalent_paths() {
|
||||
let base = temp_dir();
|
||||
let real_dir = base.join("real-workspace");
|
||||
fs::create_dir_all(&real_dir).expect("real workspace should exist");
|
||||
|
||||
// Build two stores via different but equivalent path representations:
|
||||
// the raw path and the canonicalized path.
|
||||
let raw_path = real_dir.clone();
|
||||
let canonical_path = fs::canonicalize(&real_dir).expect("canonicalize ok");
|
||||
|
||||
let store_from_raw =
|
||||
SessionStore::from_cwd(&raw_path).expect("store from raw should build");
|
||||
let store_from_canonical =
|
||||
SessionStore::from_cwd(&canonical_path).expect("store from canonical should build");
|
||||
|
||||
assert_eq!(
|
||||
store_from_raw.sessions_dir(),
|
||||
store_from_canonical.sessions_dir(),
|
||||
"equivalent paths must produce the same sessions dir (raw={} canonical={})",
|
||||
raw_path.display(),
|
||||
canonical_path.display()
|
||||
);
|
||||
|
||||
if base.exists() {
|
||||
fs::remove_dir_all(base).expect("cleanup ok");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_store_from_cwd_isolates_sessions_by_workspace() {
|
||||
// given
|
||||
@@ -832,6 +884,11 @@ mod tests {
|
||||
let workspace_b = base.join("repo-beta");
|
||||
fs::create_dir_all(&workspace_a).expect("workspace a should exist");
|
||||
fs::create_dir_all(&workspace_b).expect("workspace b should exist");
|
||||
// #151: canonicalize so test expectations match the store's canonical
|
||||
// workspace_root. Without this, the test builds sessions with a raw
|
||||
// path but the store resolves to the canonical form.
|
||||
let workspace_a = fs::canonicalize(&workspace_a).unwrap_or(workspace_a);
|
||||
let workspace_b = fs::canonicalize(&workspace_b).unwrap_or(workspace_b);
|
||||
|
||||
let store_b = SessionStore::from_cwd(&workspace_b).expect("store b should build");
|
||||
let legacy_root = workspace_b.join(".claw").join("sessions");
|
||||
@@ -865,6 +922,8 @@ mod tests {
|
||||
// given
|
||||
let base = temp_dir();
|
||||
fs::create_dir_all(&base).expect("base dir should exist");
|
||||
// #151: canonicalize for path-representation consistency with store.
|
||||
let base = fs::canonicalize(&base).unwrap_or(base);
|
||||
let store = SessionStore::from_cwd(&base).expect("store should build");
|
||||
let legacy_root = base.join(".claw").join("sessions");
|
||||
let legacy_path = legacy_root.join("legacy-safe.jsonl");
|
||||
@@ -893,6 +952,8 @@ mod tests {
|
||||
// given
|
||||
let base = temp_dir();
|
||||
fs::create_dir_all(&base).expect("base dir should exist");
|
||||
// #151: canonicalize for path-representation consistency with store.
|
||||
let base = fs::canonicalize(&base).unwrap_or(base);
|
||||
let store = SessionStore::from_cwd(&base).expect("store should build");
|
||||
let legacy_root = base.join(".claw").join("sessions");
|
||||
let legacy_path = legacy_root.join("legacy-unbound.json");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const TRUST_PROMPT_CUES: &[&str] = &[
|
||||
"do you trust the files in this folder",
|
||||
"trust the files in this folder",
|
||||
@@ -8,24 +10,121 @@ const TRUST_PROMPT_CUES: &[&str] = &[
|
||||
"yes, proceed",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// Resolution method for trust decisions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TrustPolicy {
|
||||
/// Automatically trust this path (allowlisted)
|
||||
AutoTrust,
|
||||
/// Require manual approval
|
||||
RequireApproval,
|
||||
/// Deny trust for this path
|
||||
Deny,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// Events emitted during trust resolution lifecycle.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum TrustEvent {
|
||||
TrustRequired { cwd: String },
|
||||
TrustResolved { cwd: String, policy: TrustPolicy },
|
||||
TrustDenied { cwd: String, reason: String },
|
||||
/// Trust prompt was detected and is required
|
||||
TrustRequired {
|
||||
/// Current working directory where trust is needed
|
||||
cwd: String,
|
||||
/// Optional repo identifier
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
repo: Option<String>,
|
||||
/// Optional worktree path
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
worktree: Option<String>,
|
||||
},
|
||||
/// Trust was resolved (granted)
|
||||
TrustResolved {
|
||||
/// Current working directory
|
||||
cwd: String,
|
||||
/// The policy that was applied
|
||||
policy: TrustPolicy,
|
||||
/// How the trust was resolved
|
||||
resolution: TrustResolution,
|
||||
},
|
||||
/// Trust was denied
|
||||
TrustDenied {
|
||||
/// Current working directory
|
||||
cwd: String,
|
||||
/// Reason for denial
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
/// How trust was resolved.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TrustResolution {
|
||||
/// Automatically granted due to allowlist
|
||||
AutoAllowlisted,
|
||||
/// Manually approved by user
|
||||
ManualApproval,
|
||||
}
|
||||
|
||||
/// Entry in the trust allowlist with pattern matching support.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TrustAllowlistEntry {
|
||||
/// Repository path or glob pattern to match
|
||||
pub pattern: String,
|
||||
/// Optional worktree subpath pattern
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub worktree_pattern: Option<String>,
|
||||
/// Human-readable description of why this is allowlisted
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl TrustAllowlistEntry {
|
||||
#[must_use]
|
||||
pub fn new(pattern: impl Into<String>) -> Self {
|
||||
Self {
|
||||
pattern: pattern.into(),
|
||||
worktree_pattern: None,
|
||||
description: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_worktree_pattern(mut self, pattern: impl Into<String>) -> Self {
|
||||
self.worktree_pattern = Some(pattern.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
|
||||
self.description = Some(desc.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for trust resolution with allowlist/denylist support.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TrustConfig {
|
||||
allowlisted: Vec<PathBuf>,
|
||||
denied: Vec<PathBuf>,
|
||||
/// Allowlisted paths with pattern matching
|
||||
pub allowlisted: Vec<TrustAllowlistEntry>,
|
||||
/// Denied paths (exact or prefix matches)
|
||||
pub denied: Vec<PathBuf>,
|
||||
/// Whether to emit events for trust decisions
|
||||
#[serde(default = "default_emit_events")]
|
||||
pub emit_events: bool,
|
||||
}
|
||||
|
||||
fn default_emit_events() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for TrustConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allowlisted: Vec::new(),
|
||||
denied: Vec::new(),
|
||||
emit_events: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TrustConfig {
|
||||
@@ -35,8 +134,14 @@ impl TrustConfig {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_allowlisted(mut self, path: impl Into<PathBuf>) -> Self {
|
||||
self.allowlisted.push(path.into());
|
||||
pub fn with_allowlisted(mut self, path: impl Into<String>) -> Self {
|
||||
self.allowlisted.push(TrustAllowlistEntry::new(path));
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_allowlisted_entry(mut self, entry: TrustAllowlistEntry) -> Self {
|
||||
self.allowlisted.push(entry);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -45,6 +150,147 @@ impl TrustConfig {
|
||||
self.denied.push(path.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if a path matches an allowlisted entry using glob patterns.
|
||||
#[must_use]
|
||||
pub fn is_allowlisted(
|
||||
&self,
|
||||
cwd: &str,
|
||||
worktree: Option<&str>,
|
||||
) -> Option<&TrustAllowlistEntry> {
|
||||
self.allowlisted.iter().find(|entry| {
|
||||
let path_matches = Self::pattern_matches(&entry.pattern, cwd);
|
||||
if !path_matches {
|
||||
return false;
|
||||
}
|
||||
|
||||
match (&entry.worktree_pattern, worktree) {
|
||||
(Some(wt_pattern), Some(wt)) => Self::pattern_matches(wt_pattern, wt),
|
||||
(Some(_), None) => false,
|
||||
(None, _) => true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Match a pattern against a path string.
|
||||
/// Supports exact matching and glob patterns (* and ?).
|
||||
fn pattern_matches(pattern: &str, path: &str) -> bool {
|
||||
let pattern = pattern.trim();
|
||||
let path = path.trim();
|
||||
|
||||
// Exact match
|
||||
if pattern == path {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Normalize paths for comparison
|
||||
let pattern_normalized = pattern.replace("//", "/");
|
||||
let path_normalized = path.replace("//", "/");
|
||||
|
||||
// Check if pattern is a path prefix (e.g., "/tmp/worktrees" matches "/tmp/worktrees/repo-a")
|
||||
// This handles the common case of directory containment
|
||||
if !pattern_normalized.contains('*') && !pattern_normalized.contains('?') {
|
||||
// Prefix match: pattern is a directory that contains path
|
||||
if path_normalized.starts_with(&pattern_normalized) {
|
||||
let rest = &path_normalized[pattern_normalized.len()..];
|
||||
// Must be exact match or continue with /
|
||||
return rest.is_empty() || rest.starts_with('/');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if pattern ends with wildcard (prefix match)
|
||||
if pattern_normalized.ends_with("/*") {
|
||||
let prefix = pattern_normalized.trim_end_matches("/*");
|
||||
if let Some(rest) = path_normalized.strip_prefix(prefix) {
|
||||
// Must either be exact match or continue with /
|
||||
return rest.is_empty() || rest.starts_with('/');
|
||||
}
|
||||
} else if pattern_normalized.ends_with('*') && !pattern_normalized.contains("/*/") {
|
||||
// Simple trailing * (not a path component wildcard)
|
||||
let prefix = pattern_normalized.trim_end_matches('*');
|
||||
if let Some(rest) = path_normalized.strip_prefix(prefix) {
|
||||
return rest.is_empty() || !rest.starts_with('/');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if pattern is a path component match (bounded by /)
|
||||
if path_normalized
|
||||
.split('/')
|
||||
.any(|component| component == pattern_normalized)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if pattern appears as a substring within a path component
|
||||
// (e.g., "repo" matches "/tmp/worktrees/repo-a")
|
||||
if path_normalized
|
||||
.split('/')
|
||||
.any(|component| component.contains(&pattern_normalized))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Glob matching for patterns with ? or * in the middle
|
||||
if pattern.contains('?') || pattern.contains("/*/") || pattern.starts_with("*/") {
|
||||
return Self::glob_matches(&pattern_normalized, &path_normalized);
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Simple glob pattern matching (? matches single char, * matches any sequence).
|
||||
/// Handles patterns like /tmp/*/repo-* where * matches path components.
|
||||
fn glob_matches(pattern: &str, path: &str) -> bool {
|
||||
// Use recursive backtracking for proper glob matching
|
||||
Self::glob_match_recursive(pattern, path, 0, 0)
|
||||
}
|
||||
|
||||
fn glob_match_recursive(pattern: &str, path: &str, p_idx: usize, s_idx: usize) -> bool {
|
||||
let p_chars: Vec<char> = pattern.chars().collect();
|
||||
let s_chars: Vec<char> = path.chars().collect();
|
||||
|
||||
let mut p = p_idx;
|
||||
let mut s = s_idx;
|
||||
|
||||
while p < p_chars.len() {
|
||||
match p_chars[p] {
|
||||
'*' => {
|
||||
// Try all possible matches for *
|
||||
p += 1;
|
||||
if p >= p_chars.len() {
|
||||
// * at end matches everything remaining
|
||||
return true;
|
||||
}
|
||||
// Try matching 0 or more characters
|
||||
for skip in 0..=(s_chars.len() - s) {
|
||||
if Self::glob_match_recursive(pattern, path, p, s + skip) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
'?' => {
|
||||
// ? matches exactly one character
|
||||
if s >= s_chars.len() {
|
||||
return false;
|
||||
}
|
||||
p += 1;
|
||||
s += 1;
|
||||
}
|
||||
c => {
|
||||
// Exact character match
|
||||
if s >= s_chars.len() || s_chars[s] != c {
|
||||
return false;
|
||||
}
|
||||
p += 1;
|
||||
s += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern exhausted - path must also be exhausted
|
||||
s >= s_chars.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -86,15 +332,19 @@ impl TrustResolver {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn resolve(&self, cwd: &str, screen_text: &str) -> TrustDecision {
|
||||
pub fn resolve(&self, cwd: &str, worktree: Option<&str>, screen_text: &str) -> TrustDecision {
|
||||
if !detect_trust_prompt(screen_text) {
|
||||
return TrustDecision::NotRequired;
|
||||
}
|
||||
|
||||
let repo = extract_repo_name(cwd);
|
||||
let mut events = vec![TrustEvent::TrustRequired {
|
||||
cwd: cwd.to_owned(),
|
||||
repo: repo.clone(),
|
||||
worktree: worktree.map(String::from),
|
||||
}];
|
||||
|
||||
// Check denylist first
|
||||
if let Some(matched_root) = self
|
||||
.config
|
||||
.denied
|
||||
@@ -112,15 +362,12 @@ impl TrustResolver {
|
||||
};
|
||||
}
|
||||
|
||||
if self
|
||||
.config
|
||||
.allowlisted
|
||||
.iter()
|
||||
.any(|root| path_matches(cwd, root))
|
||||
{
|
||||
// Check allowlist with pattern matching
|
||||
if self.config.is_allowlisted(cwd, worktree).is_some() {
|
||||
events.push(TrustEvent::TrustResolved {
|
||||
cwd: cwd.to_owned(),
|
||||
policy: TrustPolicy::AutoTrust,
|
||||
resolution: TrustResolution::AutoAllowlisted,
|
||||
});
|
||||
return TrustDecision::Required {
|
||||
policy: TrustPolicy::AutoTrust,
|
||||
@@ -128,6 +375,19 @@ impl TrustResolver {
|
||||
};
|
||||
}
|
||||
|
||||
// Check for manual trust resolution via screen text analysis
|
||||
if detect_manual_approval(screen_text) {
|
||||
events.push(TrustEvent::TrustResolved {
|
||||
cwd: cwd.to_owned(),
|
||||
policy: TrustPolicy::RequireApproval,
|
||||
resolution: TrustResolution::ManualApproval,
|
||||
});
|
||||
return TrustDecision::Required {
|
||||
policy: TrustPolicy::RequireApproval,
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
TrustDecision::Required {
|
||||
policy: TrustPolicy::RequireApproval,
|
||||
events,
|
||||
@@ -135,17 +395,20 @@ impl TrustResolver {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn trusts(&self, cwd: &str) -> bool {
|
||||
!self
|
||||
pub fn trusts(&self, cwd: &str, worktree: Option<&str>) -> bool {
|
||||
// Check denylist first
|
||||
let denied = self
|
||||
.config
|
||||
.denied
|
||||
.iter()
|
||||
.any(|root| path_matches(cwd, root))
|
||||
&& self
|
||||
.config
|
||||
.allowlisted
|
||||
.iter()
|
||||
.any(|root| path_matches(cwd, root))
|
||||
.any(|root| path_matches(cwd, root));
|
||||
|
||||
if denied {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check allowlist using pattern matching
|
||||
self.config.is_allowlisted(cwd, worktree).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,11 +435,240 @@ fn normalize_path(path: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
/// Extract repository name from a path for event context.
|
||||
fn extract_repo_name(cwd: &str) -> Option<String> {
|
||||
let path = Path::new(cwd);
|
||||
// Try to find a .git directory to identify repo root
|
||||
let mut current = Some(path);
|
||||
while let Some(p) = current {
|
||||
if p.join(".git").is_dir() {
|
||||
return p.file_name().map(|n| n.to_string_lossy().to_string());
|
||||
}
|
||||
current = p.parent();
|
||||
}
|
||||
// Fallback: use the last component of the path
|
||||
path.file_name().map(|n| n.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Detect if the screen text indicates manual approval was granted.
|
||||
fn detect_manual_approval(screen_text: &str) -> bool {
|
||||
let lowered = screen_text.to_ascii_lowercase();
|
||||
// Look for indicators that user manually approved
|
||||
MANUAL_APPROVAL_CUES.iter().any(|cue| lowered.contains(cue))
|
||||
}
|
||||
|
||||
const MANUAL_APPROVAL_CUES: &[&str] = &[
|
||||
"yes, i trust",
|
||||
"i trust this",
|
||||
"trusted manually",
|
||||
"approval granted",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod path_matching_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn glob_pattern_star_matches_any_sequence() {
|
||||
assert!(TrustConfig::pattern_matches("/tmp/*", "/tmp/foo"));
|
||||
assert!(TrustConfig::pattern_matches("/tmp/*", "/tmp/bar/baz"));
|
||||
assert!(!TrustConfig::pattern_matches("/tmp/*", "/other/tmp/foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_pattern_question_matches_single_char() {
|
||||
assert!(TrustConfig::pattern_matches("/tmp/test?", "/tmp/test1"));
|
||||
assert!(TrustConfig::pattern_matches("/tmp/test?", "/tmp/testA"));
|
||||
assert!(!TrustConfig::pattern_matches("/tmp/test?", "/tmp/test12"));
|
||||
assert!(!TrustConfig::pattern_matches("/tmp/test?", "/tmp/test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_matches_exact() {
|
||||
assert!(TrustConfig::pattern_matches(
|
||||
"/tmp/worktrees",
|
||||
"/tmp/worktrees"
|
||||
));
|
||||
assert!(!TrustConfig::pattern_matches(
|
||||
"/tmp/worktrees",
|
||||
"/tmp/worktrees-other"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_matches_prefix_with_wildcard() {
|
||||
assert!(TrustConfig::pattern_matches(
|
||||
"/tmp/worktrees/*",
|
||||
"/tmp/worktrees/repo-a"
|
||||
));
|
||||
assert!(TrustConfig::pattern_matches(
|
||||
"/tmp/worktrees/*",
|
||||
"/tmp/worktrees/repo-a/subdir"
|
||||
));
|
||||
assert!(!TrustConfig::pattern_matches(
|
||||
"/tmp/worktrees/*",
|
||||
"/tmp/other/repo"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_matches_contains() {
|
||||
// Pattern contained within path
|
||||
assert!(TrustConfig::pattern_matches(
|
||||
"worktrees",
|
||||
"/tmp/worktrees/repo-a"
|
||||
));
|
||||
assert!(TrustConfig::pattern_matches(
|
||||
"repo",
|
||||
"/tmp/worktrees/repo-a"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_entry_with_worktree_pattern() {
|
||||
let config = TrustConfig::new().with_allowlisted_entry(
|
||||
TrustAllowlistEntry::new("/tmp/worktrees/*")
|
||||
.with_worktree_pattern("*/.git")
|
||||
.with_description("Git worktrees"),
|
||||
);
|
||||
|
||||
// Should match when both patterns match
|
||||
assert!(config
|
||||
.is_allowlisted("/tmp/worktrees/repo-a", Some("/tmp/worktrees/repo-a/.git"))
|
||||
.is_some());
|
||||
|
||||
// Should not match when worktree pattern doesn't match
|
||||
assert!(config
|
||||
.is_allowlisted("/tmp/worktrees/repo-a", Some("/other/path"))
|
||||
.is_none());
|
||||
|
||||
// Should not match when a worktree pattern is required but no worktree is supplied
|
||||
assert!(config
|
||||
.is_allowlisted("/tmp/worktrees/repo-a", None)
|
||||
.is_none());
|
||||
|
||||
// Should match when no worktree pattern required and path matches
|
||||
let config_no_worktree = TrustConfig::new().with_allowlisted("/tmp/worktrees/*");
|
||||
assert!(config_no_worktree
|
||||
.is_allowlisted("/tmp/worktrees/repo-a", None)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_entry_returns_matched_entry() {
|
||||
let entry = TrustAllowlistEntry::new("/tmp/worktrees/*").with_description("Test worktrees");
|
||||
let config = TrustConfig::new().with_allowlisted_entry(entry.clone());
|
||||
|
||||
let matched = config.is_allowlisted("/tmp/worktrees/repo-a", None);
|
||||
assert!(matched.is_some());
|
||||
assert_eq!(
|
||||
matched.unwrap().description,
|
||||
Some("Test worktrees".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complex_glob_patterns() {
|
||||
// Multiple wildcards
|
||||
assert!(TrustConfig::pattern_matches(
|
||||
"/tmp/*/repo-*",
|
||||
"/tmp/worktrees/repo-123"
|
||||
));
|
||||
assert!(TrustConfig::pattern_matches(
|
||||
"/tmp/*/repo-*",
|
||||
"/tmp/other/repo-abc"
|
||||
));
|
||||
assert!(!TrustConfig::pattern_matches(
|
||||
"/tmp/*/repo-*",
|
||||
"/tmp/worktrees/other"
|
||||
));
|
||||
|
||||
// Mixed ? and *
|
||||
assert!(TrustConfig::pattern_matches(
|
||||
"/tmp/test?/*.txt",
|
||||
"/tmp/test1/file.txt"
|
||||
));
|
||||
assert!(TrustConfig::pattern_matches(
|
||||
"/tmp/test?/*.txt",
|
||||
"/tmp/testA/subdir/file.txt"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_serialization_roundtrip() {
|
||||
let config = TrustConfig::new()
|
||||
.with_allowlisted_entry(
|
||||
TrustAllowlistEntry::new("/tmp/worktrees/*")
|
||||
.with_worktree_pattern("*/.git")
|
||||
.with_description("Git worktrees"),
|
||||
)
|
||||
.with_denied("/tmp/malicious");
|
||||
|
||||
let json = serde_json::to_string(&config).expect("serialization failed");
|
||||
let deserialized: TrustConfig =
|
||||
serde_json::from_str(&json).expect("deserialization failed");
|
||||
|
||||
assert_eq!(config.allowlisted.len(), deserialized.allowlisted.len());
|
||||
assert_eq!(config.denied.len(), deserialized.denied.len());
|
||||
assert_eq!(config.emit_events, deserialized.emit_events);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_event_serialization() {
|
||||
let event = TrustEvent::TrustRequired {
|
||||
cwd: "/tmp/test".to_string(),
|
||||
repo: Some("test-repo".to_string()),
|
||||
worktree: Some("/tmp/test/.git".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).expect("serialization failed");
|
||||
assert!(json.contains("trust_required"));
|
||||
assert!(json.contains("/tmp/test"));
|
||||
assert!(json.contains("test-repo"));
|
||||
|
||||
let deserialized: TrustEvent = serde_json::from_str(&json).expect("deserialization failed");
|
||||
match deserialized {
|
||||
TrustEvent::TrustRequired {
|
||||
cwd,
|
||||
repo,
|
||||
worktree,
|
||||
} => {
|
||||
assert_eq!(cwd, "/tmp/test");
|
||||
assert_eq!(repo, Some("test-repo".to_string()));
|
||||
assert_eq!(worktree, Some("/tmp/test/.git".to_string()));
|
||||
}
|
||||
_ => panic!("wrong event type"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_event_resolved_serialization() {
|
||||
let event = TrustEvent::TrustResolved {
|
||||
cwd: "/tmp/test".to_string(),
|
||||
policy: TrustPolicy::AutoTrust,
|
||||
resolution: TrustResolution::AutoAllowlisted,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).expect("serialization failed");
|
||||
assert!(json.contains("trust_resolved"));
|
||||
assert!(json.contains("auto_allowlisted"));
|
||||
|
||||
let deserialized: TrustEvent = serde_json::from_str(&json).expect("deserialization failed");
|
||||
match deserialized {
|
||||
TrustEvent::TrustResolved { resolution, .. } => {
|
||||
assert_eq!(resolution, TrustResolution::AutoAllowlisted);
|
||||
}
|
||||
_ => panic!("wrong event type"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
detect_trust_prompt, path_matches_trusted_root, TrustConfig, TrustDecision, TrustEvent,
|
||||
TrustPolicy, TrustResolver,
|
||||
detect_manual_approval, detect_trust_prompt, path_matches_trusted_root,
|
||||
TrustAllowlistEntry, TrustConfig, TrustDecision, TrustEvent, TrustPolicy, TrustResolution,
|
||||
TrustResolver,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -197,7 +689,7 @@ mod tests {
|
||||
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
|
||||
|
||||
// when
|
||||
let decision = resolver.resolve("/tmp/worktrees/repo-a", "Ready for your input\n>");
|
||||
let decision = resolver.resolve("/tmp/worktrees/repo-a", None, "Ready for your input\n>");
|
||||
|
||||
// then
|
||||
assert_eq!(decision, TrustDecision::NotRequired);
|
||||
@@ -213,23 +705,23 @@ mod tests {
|
||||
// when
|
||||
let decision = resolver.resolve(
|
||||
"/tmp/worktrees/repo-a",
|
||||
None,
|
||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||
);
|
||||
|
||||
// then
|
||||
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
|
||||
assert_eq!(
|
||||
decision.events(),
|
||||
&[
|
||||
TrustEvent::TrustRequired {
|
||||
cwd: "/tmp/worktrees/repo-a".to_string(),
|
||||
},
|
||||
TrustEvent::TrustResolved {
|
||||
cwd: "/tmp/worktrees/repo-a".to_string(),
|
||||
policy: TrustPolicy::AutoTrust,
|
||||
},
|
||||
]
|
||||
);
|
||||
let events = decision.events();
|
||||
assert_eq!(events.len(), 2);
|
||||
assert!(matches!(events[0], TrustEvent::TrustRequired { .. }));
|
||||
assert!(matches!(
|
||||
events[1],
|
||||
TrustEvent::TrustResolved {
|
||||
policy: TrustPolicy::AutoTrust,
|
||||
resolution: TrustResolution::AutoAllowlisted,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -240,6 +732,7 @@ mod tests {
|
||||
// when
|
||||
let decision = resolver.resolve(
|
||||
"/tmp/other/repo-b",
|
||||
None,
|
||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||
);
|
||||
|
||||
@@ -249,6 +742,8 @@ mod tests {
|
||||
decision.events(),
|
||||
&[TrustEvent::TrustRequired {
|
||||
cwd: "/tmp/other/repo-b".to_string(),
|
||||
repo: Some("repo-b".to_string()),
|
||||
worktree: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
@@ -265,6 +760,7 @@ mod tests {
|
||||
// when
|
||||
let decision = resolver.resolve(
|
||||
"/tmp/worktrees/repo-c",
|
||||
None,
|
||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||
);
|
||||
|
||||
@@ -275,6 +771,8 @@ mod tests {
|
||||
&[
|
||||
TrustEvent::TrustRequired {
|
||||
cwd: "/tmp/worktrees/repo-c".to_string(),
|
||||
repo: Some("repo-c".to_string()),
|
||||
worktree: None,
|
||||
},
|
||||
TrustEvent::TrustDenied {
|
||||
cwd: "/tmp/worktrees/repo-c".to_string(),
|
||||
@@ -284,6 +782,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_trusts_with_glob_pattern_allowlist() {
|
||||
// given
|
||||
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees/*"));
|
||||
|
||||
// when - any repo under /tmp/worktrees should auto-trust
|
||||
let decision = resolver.resolve(
|
||||
"/tmp/worktrees/repo-a",
|
||||
None,
|
||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||
);
|
||||
|
||||
// then
|
||||
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_with_worktree_pattern_matching() {
|
||||
// given
|
||||
let config = TrustConfig::new().with_allowlisted_entry(
|
||||
TrustAllowlistEntry::new("/tmp/worktrees/*").with_worktree_pattern("*/.git"),
|
||||
);
|
||||
let resolver = TrustResolver::new(config);
|
||||
|
||||
// when - with worktree that matches the pattern
|
||||
let decision = resolver.resolve(
|
||||
"/tmp/worktrees/repo-a",
|
||||
Some("/tmp/worktrees/repo-a/.git"),
|
||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||
);
|
||||
|
||||
// then - should auto-trust because both patterns match
|
||||
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_approval_detected_from_screen_text() {
|
||||
// given
|
||||
let resolver = TrustResolver::new(TrustConfig::new());
|
||||
|
||||
// when - screen text indicates manual approval
|
||||
let decision = resolver.resolve(
|
||||
"/tmp/some/repo",
|
||||
None,
|
||||
"Do you trust the files in this folder?\nUser selected: Yes, I trust this folder",
|
||||
);
|
||||
|
||||
// then - should detect manual approval
|
||||
assert_eq!(decision.policy(), Some(TrustPolicy::RequireApproval));
|
||||
let events = decision.events();
|
||||
assert!(events.len() >= 2);
|
||||
assert!(matches!(
|
||||
events[events.len() - 1],
|
||||
TrustEvent::TrustResolved {
|
||||
resolution: TrustResolution::ManualApproval,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_prefix_does_not_match_trusted_root() {
|
||||
// given
|
||||
@@ -296,4 +854,70 @@ mod tests {
|
||||
// then
|
||||
assert!(!matched);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_manual_approval_cues() {
|
||||
assert!(detect_manual_approval(
|
||||
"User selected: Yes, I trust this folder"
|
||||
));
|
||||
assert!(detect_manual_approval(
|
||||
"I trust this repository and its contents"
|
||||
));
|
||||
assert!(detect_manual_approval("Approval granted by user"));
|
||||
assert!(!detect_manual_approval(
|
||||
"Do you trust the files in this folder?"
|
||||
));
|
||||
assert!(!detect_manual_approval("Some unrelated text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_config_default_emit_events() {
|
||||
let config = TrustConfig::default();
|
||||
assert!(config.emit_events);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_resolver_trusts_method() {
|
||||
let resolver = TrustResolver::new(
|
||||
TrustConfig::new()
|
||||
.with_allowlisted("/tmp/worktrees/*")
|
||||
.with_denied("/tmp/worktrees/bad-repo"),
|
||||
);
|
||||
|
||||
// Should trust allowlisted paths
|
||||
assert!(resolver.trusts("/tmp/worktrees/good-repo", None));
|
||||
|
||||
// Should not trust denied paths
|
||||
assert!(!resolver.trusts("/tmp/worktrees/bad-repo", None));
|
||||
|
||||
// Should not trust unknown paths
|
||||
assert!(!resolver.trusts("/tmp/other/repo", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_policy_serde_roundtrip() {
|
||||
for policy in [
|
||||
TrustPolicy::AutoTrust,
|
||||
TrustPolicy::RequireApproval,
|
||||
TrustPolicy::Deny,
|
||||
] {
|
||||
let json = serde_json::to_string(&policy).expect("serialization failed");
|
||||
let deserialized: TrustPolicy =
|
||||
serde_json::from_str(&json).expect("deserialization failed");
|
||||
assert_eq!(policy, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_resolution_serde_roundtrip() {
|
||||
for resolution in [
|
||||
TrustResolution::AutoAllowlisted,
|
||||
TrustResolution::ManualApproval,
|
||||
] {
|
||||
let json = serde_json::to_string(&resolution).expect("serialization failed");
|
||||
let deserialized: TrustResolution =
|
||||
serde_json::from_str(&json).expect("deserialization failed");
|
||||
assert_eq!(resolution, deserialized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ fn now_secs() -> u64 {
|
||||
pub enum WorkerStatus {
|
||||
Spawning,
|
||||
TrustRequired,
|
||||
ToolPermissionRequired,
|
||||
ReadyForPrompt,
|
||||
Running,
|
||||
Finished,
|
||||
@@ -41,6 +42,7 @@ impl std::fmt::Display for WorkerStatus {
|
||||
match self {
|
||||
Self::Spawning => write!(f, "spawning"),
|
||||
Self::TrustRequired => write!(f, "trust_required"),
|
||||
Self::ToolPermissionRequired => write!(f, "tool_permission_required"),
|
||||
Self::ReadyForPrompt => write!(f, "ready_for_prompt"),
|
||||
Self::Running => write!(f, "running"),
|
||||
Self::Finished => write!(f, "finished"),
|
||||
@@ -53,6 +55,7 @@ impl std::fmt::Display for WorkerStatus {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerFailureKind {
|
||||
TrustGate,
|
||||
ToolPermissionGate,
|
||||
PromptDelivery,
|
||||
Protocol,
|
||||
Provider,
|
||||
@@ -71,6 +74,7 @@ pub struct WorkerFailure {
|
||||
pub enum WorkerEventKind {
|
||||
Spawning,
|
||||
TrustRequired,
|
||||
ToolPermissionRequired,
|
||||
TrustResolved,
|
||||
ReadyForPrompt,
|
||||
PromptMisdelivery,
|
||||
@@ -104,6 +108,8 @@ pub enum WorkerPromptTarget {
|
||||
pub enum StartupFailureClassification {
|
||||
/// Trust prompt is required but not detected/resolved
|
||||
TrustRequired,
|
||||
/// Tool permission prompt is required before startup can continue
|
||||
ToolPermissionRequired,
|
||||
/// Prompt was delivered to wrong target (shell misdelivery)
|
||||
PromptMisdelivery,
|
||||
/// Prompt was sent but acceptance timed out
|
||||
@@ -116,13 +122,37 @@ pub enum StartupFailureClassification {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct StartupHealthSummary {
|
||||
/// Whether this subsystem appeared healthy at timeout.
|
||||
pub healthy: bool,
|
||||
/// Stable placeholder/source string until deeper transport and MCP probes are wired in.
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
impl StartupHealthSummary {
|
||||
fn observed(name: &str, healthy: bool) -> Self {
|
||||
let status = if healthy { "healthy" } else { "unhealthy" };
|
||||
Self {
|
||||
healthy,
|
||||
summary: format!("{name}_{status}_placeholder"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evidence bundle collected when worker startup times out without clear evidence.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct StartupEvidenceBundle {
|
||||
/// Last known worker lifecycle state before timeout
|
||||
pub last_lifecycle_state: WorkerStatus,
|
||||
/// Timestamp of the last lifecycle state transition, unix epoch seconds
|
||||
pub last_lifecycle_at: u64,
|
||||
/// The pane/command that was being executed
|
||||
pub pane_command: String,
|
||||
/// Timestamp when the pane/command snapshot was observed, unix epoch seconds
|
||||
pub pane_observed_at: u64,
|
||||
/// Timestamp when the worker command was started, unix epoch seconds
|
||||
pub command_started_at: u64,
|
||||
/// Timestamp when prompt was sent (if any), unix epoch seconds
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_sent_at: Option<u64>,
|
||||
@@ -130,10 +160,22 @@ pub struct StartupEvidenceBundle {
|
||||
pub prompt_acceptance_state: bool,
|
||||
/// Result of trust prompt detection at timeout
|
||||
pub trust_prompt_detected: bool,
|
||||
/// Result of tool permission prompt detection at timeout
|
||||
pub tool_permission_prompt_detected: bool,
|
||||
/// Age in seconds of the latest tool permission prompt, when observed
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_permission_prompt_age_seconds: Option<u64>,
|
||||
/// Whether the prompt surface exposed only a session allow path or also an always-allow path
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_permission_allow_scope: Option<ToolPermissionAllowScope>,
|
||||
/// Transport health summary (true = healthy/responsive)
|
||||
pub transport_healthy: bool,
|
||||
/// Typed transport health placeholder for future concrete probes
|
||||
pub transport_health: StartupHealthSummary,
|
||||
/// MCP health summary (true = all servers healthy)
|
||||
pub mcp_healthy: bool,
|
||||
/// Typed MCP health placeholder for future concrete probes
|
||||
pub mcp_health: StartupHealthSummary,
|
||||
/// Seconds since worker creation
|
||||
pub elapsed_seconds: u64,
|
||||
}
|
||||
@@ -146,6 +188,15 @@ pub enum WorkerEventPayload {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
resolution: Option<WorkerTrustResolution>,
|
||||
},
|
||||
ToolPermissionPrompt {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
server_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_name: Option<String>,
|
||||
prompt_age_seconds: u64,
|
||||
allow_scope: ToolPermissionAllowScope,
|
||||
prompt_preview: String,
|
||||
},
|
||||
PromptDelivery {
|
||||
prompt_preview: String,
|
||||
observed_target: WorkerPromptTarget,
|
||||
@@ -163,6 +214,14 @@ pub enum WorkerEventPayload {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolPermissionAllowScope {
|
||||
SessionOnly,
|
||||
SessionOrAlways,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerTaskReceipt {
|
||||
pub repo: String,
|
||||
@@ -194,6 +253,7 @@ pub struct Worker {
|
||||
pub auto_recover_prompt_misdelivery: bool,
|
||||
pub prompt_delivery_attempts: u32,
|
||||
pub prompt_in_flight: bool,
|
||||
pub prompt_sent_at: Option<u64>,
|
||||
pub last_prompt: Option<String>,
|
||||
pub expected_receipt: Option<WorkerTaskReceipt>,
|
||||
pub replay_prompt: Option<String>,
|
||||
@@ -243,6 +303,7 @@ impl WorkerRegistry {
|
||||
auto_recover_prompt_misdelivery,
|
||||
prompt_delivery_attempts: 0,
|
||||
prompt_in_flight: false,
|
||||
prompt_sent_at: None,
|
||||
last_prompt: None,
|
||||
expected_receipt: None,
|
||||
replay_prompt: None,
|
||||
@@ -276,6 +337,29 @@ impl WorkerRegistry {
|
||||
.ok_or_else(|| format!("worker not found: {worker_id}"))?;
|
||||
let lowered = screen_text.to_ascii_lowercase();
|
||||
|
||||
if let Some(tool_prompt) = detect_tool_permission_prompt(screen_text, &lowered) {
|
||||
worker.status = WorkerStatus::ToolPermissionRequired;
|
||||
worker.last_error = Some(WorkerFailure {
|
||||
kind: WorkerFailureKind::ToolPermissionGate,
|
||||
message: tool_prompt.message(),
|
||||
created_at: now_secs(),
|
||||
});
|
||||
push_event(
|
||||
worker,
|
||||
WorkerEventKind::ToolPermissionRequired,
|
||||
WorkerStatus::ToolPermissionRequired,
|
||||
Some("tool permission prompt detected".to_string()),
|
||||
Some(WorkerEventPayload::ToolPermissionPrompt {
|
||||
server_name: tool_prompt.server_name,
|
||||
tool_name: tool_prompt.tool_name,
|
||||
prompt_age_seconds: 0,
|
||||
allow_scope: tool_prompt.allow_scope,
|
||||
prompt_preview: tool_prompt.prompt_preview,
|
||||
}),
|
||||
);
|
||||
return Ok(worker.clone());
|
||||
}
|
||||
|
||||
if !worker.trust_gate_cleared && detect_trust_prompt(&lowered) {
|
||||
worker.status = WorkerStatus::TrustRequired;
|
||||
worker.last_error = Some(WorkerFailure {
|
||||
@@ -474,6 +558,7 @@ impl WorkerRegistry {
|
||||
|
||||
worker.prompt_delivery_attempts += 1;
|
||||
worker.prompt_in_flight = true;
|
||||
worker.prompt_sent_at = Some(now_secs());
|
||||
worker.last_prompt = Some(next_prompt.clone());
|
||||
worker.expected_receipt = task_receipt;
|
||||
worker.replay_prompt = None;
|
||||
@@ -503,7 +588,9 @@ impl WorkerRegistry {
|
||||
ready: worker.status == WorkerStatus::ReadyForPrompt,
|
||||
blocked: matches!(
|
||||
worker.status,
|
||||
WorkerStatus::TrustRequired | WorkerStatus::Failed
|
||||
WorkerStatus::TrustRequired
|
||||
| WorkerStatus::ToolPermissionRequired
|
||||
| WorkerStatus::Failed
|
||||
),
|
||||
replay_prompt_ready: worker.replay_prompt.is_some(),
|
||||
last_error: worker.last_error.clone(),
|
||||
@@ -523,6 +610,7 @@ impl WorkerRegistry {
|
||||
worker.last_error = None;
|
||||
worker.prompt_delivery_attempts = 0;
|
||||
worker.prompt_in_flight = false;
|
||||
worker.prompt_sent_at = None;
|
||||
push_event(
|
||||
worker,
|
||||
WorkerEventKind::Restarted,
|
||||
@@ -624,24 +712,44 @@ impl WorkerRegistry {
|
||||
|
||||
let now = now_secs();
|
||||
let elapsed = now.saturating_sub(worker.created_at);
|
||||
let latest_tool_permission_event = worker
|
||||
.events
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|event| event.kind == WorkerEventKind::ToolPermissionRequired);
|
||||
let tool_permission_allow_scope =
|
||||
latest_tool_permission_event.and_then(|event| match &event.payload {
|
||||
Some(WorkerEventPayload::ToolPermissionPrompt { allow_scope, .. }) => {
|
||||
Some(*allow_scope)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
|
||||
// Build evidence bundle
|
||||
let evidence = StartupEvidenceBundle {
|
||||
last_lifecycle_state: worker.status,
|
||||
last_lifecycle_at: worker.updated_at,
|
||||
pane_command: pane_command.to_string(),
|
||||
prompt_sent_at: if worker.prompt_delivery_attempts > 0 {
|
||||
Some(worker.updated_at)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
pane_observed_at: now,
|
||||
command_started_at: worker.created_at,
|
||||
prompt_sent_at: worker.prompt_sent_at,
|
||||
prompt_acceptance_state: worker.status == WorkerStatus::Running
|
||||
&& !worker.prompt_in_flight,
|
||||
trust_prompt_detected: worker
|
||||
.events
|
||||
.iter()
|
||||
.any(|e| e.kind == WorkerEventKind::TrustRequired),
|
||||
tool_permission_prompt_detected: worker
|
||||
.events
|
||||
.iter()
|
||||
.any(|e| e.kind == WorkerEventKind::ToolPermissionRequired),
|
||||
tool_permission_prompt_age_seconds: latest_tool_permission_event
|
||||
.map(|event| now.saturating_sub(event.timestamp)),
|
||||
tool_permission_allow_scope,
|
||||
transport_healthy,
|
||||
transport_health: StartupHealthSummary::observed("transport", transport_healthy),
|
||||
mcp_healthy,
|
||||
mcp_health: StartupHealthSummary::observed("mcp", mcp_healthy),
|
||||
elapsed_seconds: elapsed,
|
||||
};
|
||||
|
||||
@@ -694,6 +802,13 @@ fn classify_startup_failure(evidence: &StartupEvidenceBundle) -> StartupFailureC
|
||||
return StartupFailureClassification::TrustRequired;
|
||||
}
|
||||
|
||||
// Check for tool permission prompts that were not resolved
|
||||
if evidence.tool_permission_prompt_detected
|
||||
&& evidence.last_lifecycle_state == WorkerStatus::ToolPermissionRequired
|
||||
{
|
||||
return StartupFailureClassification::ToolPermissionRequired;
|
||||
}
|
||||
|
||||
// Check for prompt acceptance timeout
|
||||
if evidence.prompt_sent_at.is_some()
|
||||
&& !evidence.prompt_acceptance_state
|
||||
@@ -815,6 +930,140 @@ fn normalize_path(path: &str) -> PathBuf {
|
||||
std::fs::canonicalize(path).unwrap_or_else(|_| Path::new(path).to_path_buf())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ToolPermissionPromptObservation {
|
||||
server_name: Option<String>,
|
||||
tool_name: Option<String>,
|
||||
allow_scope: ToolPermissionAllowScope,
|
||||
prompt_preview: String,
|
||||
}
|
||||
|
||||
impl ToolPermissionPromptObservation {
|
||||
fn message(&self) -> String {
|
||||
match (&self.server_name, &self.tool_name) {
|
||||
(Some(server), Some(tool)) => {
|
||||
format!("worker boot blocked on tool permission prompt for {server}.{tool}")
|
||||
}
|
||||
(Some(server), None) => {
|
||||
format!("worker boot blocked on tool permission prompt for {server}")
|
||||
}
|
||||
(None, Some(tool)) => {
|
||||
format!("worker boot blocked on tool permission prompt for {tool}")
|
||||
}
|
||||
(None, None) => "worker boot blocked on tool permission prompt".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_tool_permission_prompt(
|
||||
screen_text: &str,
|
||||
lowered: &str,
|
||||
) -> Option<ToolPermissionPromptObservation> {
|
||||
let looks_like_prompt = lowered.contains("allow the")
|
||||
&& lowered.contains("server")
|
||||
&& lowered.contains("tool")
|
||||
&& lowered.contains("run");
|
||||
let looks_like_tool_gate = lowered.contains("allow tool") && lowered.contains("run");
|
||||
if !looks_like_prompt && !looks_like_tool_gate {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prompt_line = screen_text
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|line| {
|
||||
let lowered_line = line.to_ascii_lowercase();
|
||||
lowered_line.contains("allow")
|
||||
&& lowered_line.contains("tool")
|
||||
&& (lowered_line.contains("run") || lowered_line.contains("server"))
|
||||
})
|
||||
.unwrap_or(screen_text)
|
||||
.trim();
|
||||
|
||||
let tool_name = extract_quoted_value(prompt_line)
|
||||
.or_else(|| extract_after(prompt_line, "tool ").map(|token| normalize_tool_token(&token)));
|
||||
let server_name = extract_between(prompt_line, "the ", " server")
|
||||
.map(|server| server.trim_end_matches(" MCP").to_string())
|
||||
.or_else(|| {
|
||||
tool_name
|
||||
.as_deref()
|
||||
.and_then(extract_server_from_qualified_tool)
|
||||
});
|
||||
|
||||
Some(ToolPermissionPromptObservation {
|
||||
server_name,
|
||||
tool_name,
|
||||
allow_scope: detect_tool_permission_allow_scope(lowered),
|
||||
prompt_preview: prompt_preview(prompt_line),
|
||||
})
|
||||
}
|
||||
|
||||
fn detect_tool_permission_allow_scope(lowered: &str) -> ToolPermissionAllowScope {
|
||||
let always_allow_capable = [
|
||||
"always allow",
|
||||
"allow always",
|
||||
"allow this tool always",
|
||||
"allow for all sessions",
|
||||
]
|
||||
.iter()
|
||||
.any(|needle| lowered.contains(needle));
|
||||
|
||||
if always_allow_capable {
|
||||
return ToolPermissionAllowScope::SessionOrAlways;
|
||||
}
|
||||
|
||||
let session_allow_capable = [
|
||||
"allow once",
|
||||
"allow for this session",
|
||||
"allow this session",
|
||||
"yes, allow",
|
||||
]
|
||||
.iter()
|
||||
.any(|needle| lowered.contains(needle));
|
||||
|
||||
if session_allow_capable {
|
||||
ToolPermissionAllowScope::SessionOnly
|
||||
} else {
|
||||
ToolPermissionAllowScope::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_quoted_value(text: &str) -> Option<String> {
|
||||
let start = text.find('"')? + 1;
|
||||
let rest = &text[start..];
|
||||
let end = rest.find('"')?;
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
|
||||
fn extract_between(text: &str, prefix: &str, suffix: &str) -> Option<String> {
|
||||
let start = text.find(prefix)? + prefix.len();
|
||||
let rest = &text[start..];
|
||||
let end = rest.find(suffix)?;
|
||||
let value = rest[..end].trim();
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
fn extract_after(text: &str, prefix: &str) -> Option<String> {
|
||||
let start = text.to_ascii_lowercase().find(prefix)? + prefix.len();
|
||||
let value = text[start..]
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.trim_matches(|ch: char| ch == '?' || ch == ':' || ch == '"' || ch == '\'');
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
fn normalize_tool_token(token: &str) -> String {
|
||||
token
|
||||
.trim_matches(|ch: char| ch == '?' || ch == ':' || ch == '"' || ch == '\'')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn extract_server_from_qualified_tool(tool: &str) -> Option<String> {
|
||||
let rest = tool.strip_prefix("mcp__")?;
|
||||
let (server, _) = rest.split_once("__")?;
|
||||
(!server.is_empty()).then(|| server.to_string())
|
||||
}
|
||||
|
||||
fn detect_trust_prompt(lowered: &str) -> bool {
|
||||
[
|
||||
"do you trust the files in this folder",
|
||||
@@ -1134,6 +1383,96 @@ mod tests {
|
||||
assert!(detect_ready_for_prompt("│ >", "│ >"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_permission_prompt_blocks_worker_with_structured_event() {
|
||||
let registry = WorkerRegistry::new();
|
||||
let worker = registry.create("/tmp/repo-mcp", &[], true);
|
||||
|
||||
let blocked = registry
|
||||
.observe(
|
||||
&worker.worker_id,
|
||||
"Allow the omx_memory MCP server to run tool \"project_memory_read\"?\n\
|
||||
1. Yes, allow once\n\
|
||||
2. Always allow this tool",
|
||||
)
|
||||
.expect("tool permission observe should succeed");
|
||||
|
||||
assert_eq!(blocked.status, WorkerStatus::ToolPermissionRequired);
|
||||
assert_eq!(
|
||||
blocked
|
||||
.last_error
|
||||
.as_ref()
|
||||
.expect("tool permission error should exist")
|
||||
.kind,
|
||||
WorkerFailureKind::ToolPermissionGate
|
||||
);
|
||||
let event = blocked
|
||||
.events
|
||||
.iter()
|
||||
.find(|event| event.kind == WorkerEventKind::ToolPermissionRequired)
|
||||
.expect("tool permission event should exist");
|
||||
assert_eq!(
|
||||
event.payload,
|
||||
Some(WorkerEventPayload::ToolPermissionPrompt {
|
||||
server_name: Some("omx_memory".to_string()),
|
||||
tool_name: Some("project_memory_read".to_string()),
|
||||
prompt_age_seconds: 0,
|
||||
allow_scope: ToolPermissionAllowScope::SessionOrAlways,
|
||||
prompt_preview: prompt_preview(
|
||||
"Allow the omx_memory MCP server to run tool \"project_memory_read\"?",
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
let readiness = registry
|
||||
.await_ready(&worker.worker_id)
|
||||
.expect("ready snapshot should load");
|
||||
assert!(readiness.blocked);
|
||||
assert!(!readiness.ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_timeout_classifies_tool_permission_prompt() {
|
||||
let registry = WorkerRegistry::new();
|
||||
let worker = registry.create("/tmp/repo-mcp-timeout", &[], true);
|
||||
|
||||
registry
|
||||
.observe(
|
||||
&worker.worker_id,
|
||||
"Allow the omx_memory MCP server to run tool \"notepad_read\"?\n\
|
||||
1. Yes, allow once",
|
||||
)
|
||||
.expect("tool permission observe should succeed");
|
||||
|
||||
let timed_out = registry
|
||||
.observe_startup_timeout(&worker.worker_id, "claw prompt", true, true)
|
||||
.expect("startup timeout observe should succeed");
|
||||
let event = timed_out
|
||||
.events
|
||||
.iter()
|
||||
.find(|event| event.kind == WorkerEventKind::StartupNoEvidence)
|
||||
.expect("startup no evidence event should exist");
|
||||
|
||||
match event.payload.as_ref() {
|
||||
Some(WorkerEventPayload::StartupNoEvidence {
|
||||
classification,
|
||||
evidence,
|
||||
}) => {
|
||||
assert_eq!(
|
||||
*classification,
|
||||
StartupFailureClassification::ToolPermissionRequired
|
||||
);
|
||||
assert!(evidence.tool_permission_prompt_detected);
|
||||
assert_eq!(
|
||||
evidence.tool_permission_allow_scope,
|
||||
Some(ToolPermissionAllowScope::SessionOnly)
|
||||
);
|
||||
assert!(evidence.tool_permission_prompt_age_seconds.is_some());
|
||||
}
|
||||
_ => panic!("expected StartupNoEvidence payload"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_misdelivery_is_detected_and_replay_can_be_rearmed() {
|
||||
let registry = WorkerRegistry::new();
|
||||
@@ -1534,8 +1873,16 @@ mod tests {
|
||||
"last state should be spawning"
|
||||
);
|
||||
assert_eq!(evidence.pane_command, "cargo test");
|
||||
assert!(evidence.command_started_at <= evidence.pane_observed_at);
|
||||
assert!(evidence.last_lifecycle_at <= evidence.pane_observed_at);
|
||||
assert!(!evidence.transport_healthy);
|
||||
assert!(!evidence.transport_health.healthy);
|
||||
assert!(evidence
|
||||
.transport_health
|
||||
.summary
|
||||
.contains("transport_unhealthy"));
|
||||
assert!(evidence.mcp_healthy);
|
||||
assert!(evidence.mcp_health.healthy);
|
||||
assert_eq!(*classification, StartupFailureClassification::TransportDead);
|
||||
}
|
||||
_ => panic!(
|
||||
@@ -1626,16 +1973,63 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_timeout_preserves_original_prompt_sent_timestamp() {
|
||||
let registry = WorkerRegistry::new();
|
||||
let worker = registry.create("/tmp/repo-prompt-timestamp", &[], true);
|
||||
|
||||
registry
|
||||
.observe(&worker.worker_id, "Ready for input\n>")
|
||||
.expect("ready observe should succeed");
|
||||
let prompted = registry
|
||||
.send_prompt(
|
||||
&worker.worker_id,
|
||||
Some("Run timestamp-sensitive work"),
|
||||
None,
|
||||
)
|
||||
.expect("prompt send should succeed");
|
||||
let sent_at = prompted
|
||||
.prompt_sent_at
|
||||
.expect("prompt send should record a prompt timestamp");
|
||||
|
||||
let timed_out = registry
|
||||
.observe_startup_timeout(&worker.worker_id, "claw worker", true, true)
|
||||
.expect("startup timeout observe should succeed");
|
||||
|
||||
let event = timed_out
|
||||
.events
|
||||
.iter()
|
||||
.find(|e| e.kind == WorkerEventKind::StartupNoEvidence)
|
||||
.expect("startup no evidence event should exist");
|
||||
|
||||
match event.payload.as_ref() {
|
||||
Some(WorkerEventPayload::StartupNoEvidence { evidence, .. }) => {
|
||||
assert_eq!(evidence.prompt_sent_at, Some(sent_at));
|
||||
assert!(evidence.last_lifecycle_at <= evidence.pane_observed_at);
|
||||
assert!(evidence.command_started_at <= sent_at);
|
||||
}
|
||||
_ => panic!("expected StartupNoEvidence payload"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_evidence_bundle_serializes_correctly() {
|
||||
let bundle = StartupEvidenceBundle {
|
||||
last_lifecycle_state: WorkerStatus::Running,
|
||||
last_lifecycle_at: 1_234_567_889,
|
||||
pane_command: "test command".to_string(),
|
||||
pane_observed_at: 1_234_567_891,
|
||||
command_started_at: 1_234_567_800,
|
||||
prompt_sent_at: Some(1_234_567_890),
|
||||
prompt_acceptance_state: false,
|
||||
trust_prompt_detected: true,
|
||||
tool_permission_prompt_detected: false,
|
||||
tool_permission_prompt_age_seconds: None,
|
||||
tool_permission_allow_scope: None,
|
||||
transport_healthy: true,
|
||||
transport_health: StartupHealthSummary::observed("transport", true),
|
||||
mcp_healthy: false,
|
||||
mcp_health: StartupHealthSummary::observed("mcp", false),
|
||||
elapsed_seconds: 60,
|
||||
};
|
||||
|
||||
@@ -1644,8 +2038,13 @@ mod tests {
|
||||
assert!(json.contains("\"pane_command\""));
|
||||
assert!(json.contains("\"prompt_sent_at\":1234567890"));
|
||||
assert!(json.contains("\"trust_prompt_detected\":true"));
|
||||
assert!(json.contains("\"last_lifecycle_at\":1234567889"));
|
||||
assert!(json.contains("\"pane_observed_at\":1234567891"));
|
||||
assert!(json.contains("\"command_started_at\":1234567800"));
|
||||
assert!(json.contains("\"transport_healthy\":true"));
|
||||
assert!(json.contains("\"transport_health\""));
|
||||
assert!(json.contains("\"mcp_healthy\":false"));
|
||||
assert!(json.contains("\"mcp_health\""));
|
||||
|
||||
let deserialized: StartupEvidenceBundle =
|
||||
serde_json::from_str(&json).expect("should deserialize");
|
||||
@@ -1657,12 +2056,20 @@ mod tests {
|
||||
fn classify_startup_failure_detects_transport_dead() {
|
||||
let evidence = StartupEvidenceBundle {
|
||||
last_lifecycle_state: WorkerStatus::Spawning,
|
||||
last_lifecycle_at: 10,
|
||||
pane_command: "test".to_string(),
|
||||
pane_observed_at: 40,
|
||||
command_started_at: 1,
|
||||
prompt_sent_at: None,
|
||||
prompt_acceptance_state: false,
|
||||
trust_prompt_detected: false,
|
||||
tool_permission_prompt_detected: false,
|
||||
tool_permission_prompt_age_seconds: None,
|
||||
tool_permission_allow_scope: None,
|
||||
transport_healthy: false,
|
||||
transport_health: StartupHealthSummary::observed("transport", false),
|
||||
mcp_healthy: true,
|
||||
mcp_health: StartupHealthSummary::observed("mcp", true),
|
||||
elapsed_seconds: 30,
|
||||
};
|
||||
|
||||
@@ -1674,12 +2081,20 @@ mod tests {
|
||||
fn classify_startup_failure_defaults_to_unknown() {
|
||||
let evidence = StartupEvidenceBundle {
|
||||
last_lifecycle_state: WorkerStatus::Spawning,
|
||||
last_lifecycle_at: 10,
|
||||
pane_command: "test".to_string(),
|
||||
pane_observed_at: 40,
|
||||
command_started_at: 1,
|
||||
prompt_sent_at: None,
|
||||
prompt_acceptance_state: false,
|
||||
trust_prompt_detected: false,
|
||||
tool_permission_prompt_detected: false,
|
||||
tool_permission_prompt_age_seconds: None,
|
||||
tool_permission_allow_scope: None,
|
||||
transport_healthy: true,
|
||||
transport_health: StartupHealthSummary::observed("transport", true),
|
||||
mcp_healthy: true,
|
||||
mcp_health: StartupHealthSummary::observed("mcp", true),
|
||||
elapsed_seconds: 10,
|
||||
};
|
||||
|
||||
@@ -1687,18 +2102,54 @@ mod tests {
|
||||
assert_eq!(classification, StartupFailureClassification::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_startup_failure_detects_prompt_misdelivery_after_timeout() {
|
||||
let evidence = StartupEvidenceBundle {
|
||||
last_lifecycle_state: WorkerStatus::ReadyForPrompt,
|
||||
last_lifecycle_at: 10,
|
||||
pane_command: "test".to_string(),
|
||||
pane_observed_at: 45,
|
||||
command_started_at: 1,
|
||||
prompt_sent_at: Some(10),
|
||||
prompt_acceptance_state: false,
|
||||
trust_prompt_detected: false,
|
||||
tool_permission_prompt_detected: false,
|
||||
tool_permission_prompt_age_seconds: None,
|
||||
tool_permission_allow_scope: None,
|
||||
transport_healthy: true,
|
||||
transport_health: StartupHealthSummary::observed("transport", true),
|
||||
mcp_healthy: true,
|
||||
mcp_health: StartupHealthSummary::observed("mcp", true),
|
||||
elapsed_seconds: 31,
|
||||
};
|
||||
|
||||
let classification = classify_startup_failure(&evidence);
|
||||
assert_eq!(
|
||||
classification,
|
||||
StartupFailureClassification::PromptMisdelivery
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_startup_failure_detects_worker_crashed() {
|
||||
// Worker crashed scenario: transport healthy but MCP unhealthy
|
||||
// Don't have prompt in flight (no prompt_sent_at) to avoid matching PromptAcceptanceTimeout
|
||||
let evidence = StartupEvidenceBundle {
|
||||
last_lifecycle_state: WorkerStatus::Spawning,
|
||||
last_lifecycle_at: 10,
|
||||
pane_command: "test".to_string(),
|
||||
pane_observed_at: 40,
|
||||
command_started_at: 1,
|
||||
prompt_sent_at: None, // No prompt sent yet
|
||||
prompt_acceptance_state: false,
|
||||
trust_prompt_detected: false,
|
||||
tool_permission_prompt_detected: false,
|
||||
tool_permission_prompt_age_seconds: None,
|
||||
tool_permission_allow_scope: None,
|
||||
transport_healthy: true,
|
||||
mcp_healthy: false, // MCP unhealthy but transport healthy suggests crash
|
||||
transport_health: StartupHealthSummary::observed("transport", true),
|
||||
mcp_healthy: false,
|
||||
mcp_health: StartupHealthSummary::observed("mcp", false), // MCP unhealthy but transport healthy suggests crash
|
||||
elapsed_seconds: 45,
|
||||
};
|
||||
|
||||
|
||||
81
rust/crates/runtime/tests/fixtures/g004_contract_bundle.valid.json
vendored
Normal file
81
rust/crates/runtime/tests/fixtures/g004_contract_bundle.valid.json
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"schemaVersion": "g004.contract.bundle.v1",
|
||||
"laneEvents": [
|
||||
{
|
||||
"event": "lane.started",
|
||||
"status": "running",
|
||||
"emittedAt": "2026-05-14T00:00:00Z",
|
||||
"metadata": {
|
||||
"seq": 1,
|
||||
"provenance": "live_lane",
|
||||
"emitterIdentity": "worker-1",
|
||||
"environmentLabel": "team-g004"
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": "lane.finished",
|
||||
"status": "completed",
|
||||
"emittedAt": "2026-05-14T00:00:10Z",
|
||||
"metadata": {
|
||||
"seq": 2,
|
||||
"provenance": "live_lane",
|
||||
"emitterIdentity": "worker-1",
|
||||
"environmentLabel": "team-g004",
|
||||
"eventFingerprint": "terminal-fp-001"
|
||||
}
|
||||
}
|
||||
],
|
||||
"reports": [
|
||||
{
|
||||
"schemaVersion": "g004.report.v1",
|
||||
"reportId": "report-g004-fixture",
|
||||
"identity": { "contentHash": "sha256:report-content" },
|
||||
"projection": { "provenance": "runtime.event_projection.v1" },
|
||||
"redaction": { "provenance": "runtime.redaction_policy.v1" },
|
||||
"consumerCapabilities": ["facts", "field_deltas", "redaction_provenance"],
|
||||
"findings": [
|
||||
{
|
||||
"kind": "fact",
|
||||
"confidence": "high",
|
||||
"statement": "lane event reached terminal state"
|
||||
},
|
||||
{
|
||||
"kind": "hypothesis",
|
||||
"confidence": "medium",
|
||||
"statement": "consumer can reconcile the terminal fingerprint"
|
||||
},
|
||||
{
|
||||
"kind": "negative_evidence",
|
||||
"confidence": "high",
|
||||
"statement": "no duplicate terminal event appears in this fixture"
|
||||
}
|
||||
],
|
||||
"fieldDeltas": [
|
||||
{
|
||||
"field": "/laneEvents/1/status",
|
||||
"previousHash": "sha256:running",
|
||||
"currentHash": "sha256:completed",
|
||||
"attribution": "worker-1 terminal reconciliation"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"approvalTokens": [
|
||||
{
|
||||
"tokenId": "approval-token-fixture",
|
||||
"owner": "leader-fixed",
|
||||
"scope": "g004.contract.bundle.fixture",
|
||||
"issuedAt": "2026-05-14T00:00:01Z",
|
||||
"oneTimeUse": true,
|
||||
"replayPreventionNonce": "nonce-fixture-001",
|
||||
"delegationChain": [
|
||||
{
|
||||
"from": "leader-fixed",
|
||||
"to": "worker-3",
|
||||
"action": "validate-g004-contract-fixture",
|
||||
"at": "2026-05-14T00:00:02Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
11
rust/crates/runtime/tests/fixtures/report_schema_v1/README.md
vendored
Normal file
11
rust/crates/runtime/tests/fixtures/report_schema_v1/README.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Report schema v1 fixture set
|
||||
|
||||
Validated by `cargo test -p runtime report_schema -- --nocapture`.
|
||||
|
||||
The in-code fixture in `runtime::report_schema::tests::fixture_report` covers:
|
||||
- fact / hypothesis / confidence labels
|
||||
- negative evidence with checked surfaces and query window
|
||||
- field-level delta attribution
|
||||
- canonical report id plus content hash
|
||||
- deterministic projection/redaction provenance
|
||||
- consumer capability negotiation and downgraded projections
|
||||
80
rust/crates/runtime/tests/g004_conformance.rs
Normal file
80
rust/crates/runtime/tests/g004_conformance.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use runtime::g004_conformance::{is_g004_contract_bundle_valid, validate_g004_contract_bundle};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn valid_bundle() -> Value {
|
||||
serde_json::from_str(include_str!("fixtures/g004_contract_bundle.valid.json"))
|
||||
.expect("valid fixture JSON should parse")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_g004_contract_bundle_fixture_passes_conformance() {
|
||||
let fixture = valid_bundle();
|
||||
|
||||
let errors = validate_g004_contract_bundle(&fixture);
|
||||
|
||||
assert!(
|
||||
errors.is_empty(),
|
||||
"unexpected conformance errors: {errors:?}"
|
||||
);
|
||||
assert!(is_g004_contract_bundle_valid(&fixture));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn g004_conformance_reports_machine_readable_paths_for_contract_gaps() {
|
||||
let invalid = json!({
|
||||
"schemaVersion": "g004.contract.bundle.v1",
|
||||
"laneEvents": [
|
||||
{
|
||||
"event": "lane.finished",
|
||||
"status": "completed",
|
||||
"emittedAt": "2026-05-14T00:00:10Z",
|
||||
"metadata": {
|
||||
"seq": 1,
|
||||
"provenance": "live_lane",
|
||||
"emitterIdentity": "worker-1",
|
||||
"environmentLabel": "team-g004"
|
||||
}
|
||||
}
|
||||
],
|
||||
"reports": [
|
||||
{
|
||||
"schemaVersion": "g004.report.v1",
|
||||
"reportId": "report-with-gaps",
|
||||
"identity": { "contentHash": "sha256:report-content" },
|
||||
"projection": { "provenance": "runtime.event_projection.v1" },
|
||||
"redaction": { "provenance": "runtime.redaction_policy.v1" },
|
||||
"consumerCapabilities": [],
|
||||
"findings": [
|
||||
{
|
||||
"kind": "guess",
|
||||
"confidence": "certain",
|
||||
"statement": "bad labels should be rejected"
|
||||
}
|
||||
],
|
||||
"fieldDeltas": []
|
||||
}
|
||||
],
|
||||
"approvalTokens": [
|
||||
{
|
||||
"tokenId": "approval-token-fixture",
|
||||
"owner": "leader-fixed",
|
||||
"scope": "g004.contract.bundle.fixture",
|
||||
"issuedAt": "2026-05-14T00:00:01Z",
|
||||
"oneTimeUse": false,
|
||||
"replayPreventionNonce": "nonce-fixture-001",
|
||||
"delegationChain": []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let errors = validate_g004_contract_bundle(&invalid);
|
||||
let paths: Vec<&str> = errors.iter().map(|error| error.path.as_str()).collect();
|
||||
|
||||
assert!(paths.contains(&"/laneEvents/0/metadata/eventFingerprint"));
|
||||
assert!(paths.contains(&"/reports/0/consumerCapabilities"));
|
||||
assert!(paths.contains(&"/reports/0/findings/0/kind"));
|
||||
assert!(paths.contains(&"/reports/0/findings/0/confidence"));
|
||||
assert!(paths.contains(&"/reports/0/fieldDeltas"));
|
||||
assert!(paths.contains(&"/approvalTokens/0/oneTimeUse"));
|
||||
assert!(paths.contains(&"/approvalTokens/0/delegationChain"));
|
||||
}
|
||||
@@ -22,7 +22,7 @@ fn stale_branch_detection_flows_into_policy_engine() {
|
||||
let stale_context = LaneContext::new(
|
||||
"stale-lane",
|
||||
0,
|
||||
Duration::from_secs(2 * 60 * 60), // 2 hours stale
|
||||
Duration::from_hours(2), // 2 hours stale
|
||||
LaneBlocker::None,
|
||||
ReviewStatus::Pending,
|
||||
DiffScope::Full,
|
||||
@@ -49,7 +49,7 @@ fn fresh_branch_does_not_trigger_stale_policy() {
|
||||
let fresh_context = LaneContext::new(
|
||||
"fresh-lane",
|
||||
0,
|
||||
Duration::from_secs(30 * 60), // 30 min stale — under 1 hour threshold
|
||||
Duration::from_mins(30), // 30 min stale — under 1 hour threshold
|
||||
LaneBlocker::None,
|
||||
ReviewStatus::Pending,
|
||||
DiffScope::Full,
|
||||
@@ -96,9 +96,7 @@ fn green_contract_unsatisfied_blocks_merge() {
|
||||
false,
|
||||
);
|
||||
|
||||
// This is a conceptual test — we need a way to express "requires workspace green"
|
||||
// Currently LaneContext has raw green_level: u8, not a contract
|
||||
// For now we just verify the policy condition works
|
||||
// The context has a test level but lacks the full green contract, so merge stays blocked.
|
||||
let engine = PolicyEngine::new(vec![PolicyRule::new(
|
||||
"workspace-green-required",
|
||||
PolicyCondition::GreenAt { level: 3 }, // GreenLevel::Workspace
|
||||
@@ -212,8 +210,8 @@ fn end_to_end_stale_lane_gets_merge_forward_action() {
|
||||
// when: build context and evaluate policy
|
||||
let context = LaneContext::new(
|
||||
"lane-9411",
|
||||
3, // Workspace green
|
||||
Duration::from_secs(5 * 60 * 60), // 5 hours stale, definitely over threshold
|
||||
3, // Workspace green
|
||||
Duration::from_hours(5), // 5 hours stale, definitely over threshold
|
||||
LaneBlocker::None,
|
||||
ReviewStatus::Approved,
|
||||
DiffScope::Scoped,
|
||||
@@ -261,13 +259,14 @@ fn end_to_end_stale_lane_gets_merge_forward_action() {
|
||||
fn fresh_approved_lane_gets_merge_action() {
|
||||
let context = LaneContext::new(
|
||||
"fresh-approved-lane",
|
||||
3, // Workspace green
|
||||
Duration::from_secs(30 * 60), // 30 min — under 1 hour threshold = fresh
|
||||
3, // Workspace green
|
||||
Duration::from_mins(30), // 30 min — under 1 hour threshold = fresh
|
||||
LaneBlocker::None,
|
||||
ReviewStatus::Approved,
|
||||
DiffScope::Scoped,
|
||||
false,
|
||||
);
|
||||
)
|
||||
.with_green_contract_satisfied(true);
|
||||
|
||||
let engine = PolicyEngine::new(vec![PolicyRule::new(
|
||||
"merge-if-green-approved-not-stale",
|
||||
@@ -347,7 +346,7 @@ fn worker_provider_failure_flows_through_recovery_to_policy() {
|
||||
// (Simulating the policy check that would happen after successful recovery)
|
||||
let recovery_success = matches!(result, RecoveryResult::Recovered { .. });
|
||||
let green_level = 3; // Workspace green
|
||||
let not_stale = Duration::from_secs(30 * 60); // 30 min — fresh
|
||||
let not_stale = Duration::from_mins(30); // 30 min — fresh
|
||||
|
||||
let post_recovery_context = LaneContext::new(
|
||||
"recovered-lane",
|
||||
@@ -357,7 +356,8 @@ fn worker_provider_failure_flows_through_recovery_to_policy() {
|
||||
ReviewStatus::Approved,
|
||||
DiffScope::Scoped,
|
||||
false,
|
||||
);
|
||||
)
|
||||
.with_green_contract_satisfied(true);
|
||||
|
||||
let policy_engine = PolicyEngine::new(vec![
|
||||
// Rule: if recovered from failure + green + approved -> merge
|
||||
|
||||
@@ -27,6 +27,18 @@ impl InitStatus {
|
||||
Self::Skipped => "skipped (already exists)",
|
||||
}
|
||||
}
|
||||
|
||||
/// Machine-stable identifier for structured output (#142).
|
||||
/// Unlike `label()`, this never changes wording: claws can switch on
|
||||
/// these values without brittle substring matching.
|
||||
#[must_use]
|
||||
pub(crate) fn json_tag(self) -> &'static str {
|
||||
match self {
|
||||
Self::Created => "created",
|
||||
Self::Updated => "updated",
|
||||
Self::Skipped => "skipped",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -58,6 +70,36 @@ impl InitReport {
|
||||
lines.push(" Next step Review and tailor the generated guidance".to_string());
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Summary constant that claws can embed in JSON output without having
|
||||
/// to read it out of the human-formatted `message` string (#142).
|
||||
pub(crate) const NEXT_STEP: &'static str = "Review and tailor the generated guidance";
|
||||
|
||||
/// Artifact names that ended in the given status. Used to build the
|
||||
/// structured `created[]`/`updated[]`/`skipped[]` arrays for #142.
|
||||
#[must_use]
|
||||
pub(crate) fn artifacts_with_status(&self, status: InitStatus) -> Vec<String> {
|
||||
self.artifacts
|
||||
.iter()
|
||||
.filter(|artifact| artifact.status == status)
|
||||
.map(|artifact| artifact.name.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Structured artifact list for JSON output (#142). Each entry carries
|
||||
/// `name` and machine-stable `status` tag.
|
||||
#[must_use]
|
||||
pub(crate) fn artifact_json_entries(&self) -> Vec<serde_json::Value> {
|
||||
self.artifacts
|
||||
.iter()
|
||||
.map(|artifact| {
|
||||
serde_json::json!({
|
||||
"name": artifact.name,
|
||||
"status": artifact.status.json_tag(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
@@ -333,7 +375,7 @@ fn framework_notes(detection: &RepoDetection) -> Vec<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{initialize_repo, render_init_claude_md};
|
||||
use super::{initialize_repo, render_init_claude_md, InitStatus};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -413,6 +455,63 @@ mod tests {
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifacts_with_status_partitions_fresh_and_idempotent_runs() {
|
||||
// #142: the structured JSON output needs to be able to partition
|
||||
// artifacts into created/updated/skipped without substring matching
|
||||
// the human-formatted `message` string.
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(&root).expect("create root");
|
||||
|
||||
let fresh = initialize_repo(&root).expect("fresh init should succeed");
|
||||
let created_names = fresh.artifacts_with_status(InitStatus::Created);
|
||||
assert_eq!(
|
||||
created_names,
|
||||
vec![
|
||||
".claw/".to_string(),
|
||||
".claw.json".to_string(),
|
||||
".gitignore".to_string(),
|
||||
"CLAUDE.md".to_string(),
|
||||
],
|
||||
"fresh init should place all four artifacts in created[]"
|
||||
);
|
||||
assert!(
|
||||
fresh.artifacts_with_status(InitStatus::Skipped).is_empty(),
|
||||
"fresh init should have no skipped artifacts"
|
||||
);
|
||||
|
||||
let second = initialize_repo(&root).expect("second init should succeed");
|
||||
let skipped_names = second.artifacts_with_status(InitStatus::Skipped);
|
||||
assert_eq!(
|
||||
skipped_names,
|
||||
vec![
|
||||
".claw/".to_string(),
|
||||
".claw.json".to_string(),
|
||||
".gitignore".to_string(),
|
||||
"CLAUDE.md".to_string(),
|
||||
],
|
||||
"idempotent init should place all four artifacts in skipped[]"
|
||||
);
|
||||
assert!(
|
||||
second.artifacts_with_status(InitStatus::Created).is_empty(),
|
||||
"idempotent init should have no created artifacts"
|
||||
);
|
||||
|
||||
// artifact_json_entries() uses the machine-stable `json_tag()` which
|
||||
// never changes wording (unlike `label()` which says "skipped (already exists)").
|
||||
let entries = second.artifact_json_entries();
|
||||
assert_eq!(entries.len(), 4);
|
||||
for entry in &entries {
|
||||
let status = entry.get("status").and_then(|v| v.as_str()).unwrap();
|
||||
assert_eq!(
|
||||
status, "skipped",
|
||||
"machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'"
|
||||
);
|
||||
}
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_init_template_mentions_detected_python_and_nextjs_markers() {
|
||||
let root = temp_dir();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
|
||||
use serde_json::Value;
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
@@ -125,6 +126,123 @@ fn compact_flag_streaming_text_only_emits_final_message_text() {
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_prompt_mode_prints_final_assistant_text_after_spinner() {
|
||||
// given a workspace pointed at the mock Anthropic service running the
|
||||
// streaming_text scenario which only emits a single assistant text block
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let workspace = unique_temp_dir("text-prompt-mode");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
// when we invoke claw in normal text prompt mode for the streaming text scenario
|
||||
let prompt = format!("{SCENARIO_PREFIX}streaming_text");
|
||||
let output = run_claw(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&base_url,
|
||||
&[
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
&prompt,
|
||||
],
|
||||
);
|
||||
|
||||
// then stdout should contain the final assistant text, not just spinner output
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"text prompt run should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let plain_stdout = strip_ansi_codes(&stdout);
|
||||
assert!(
|
||||
plain_stdout.contains("Mock streaming says hello from the parity harness."),
|
||||
"text prompt stdout should include the assistant text ({stdout:?})"
|
||||
);
|
||||
assert!(
|
||||
plain_stdout.contains("✔ ✨ Done"),
|
||||
"text prompt stdout should still include spinner completion ({stdout:?})"
|
||||
);
|
||||
assert!(
|
||||
plain_stdout
|
||||
.lines()
|
||||
.any(|line| line == "Mock streaming says hello from the parity harness."),
|
||||
"text prompt stdout should print the assistant text as its own line ({stdout:?})"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_flag_with_json_output_emits_structured_json() {
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let workspace = unique_temp_dir("compact-json");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
let prompt = format!("{SCENARIO_PREFIX}streaming_text");
|
||||
let output = run_claw(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&base_url,
|
||||
&[
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
"--output-format",
|
||||
"json",
|
||||
"--compact",
|
||||
&prompt,
|
||||
],
|
||||
);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"compact json run should succeed
|
||||
stdout:
|
||||
{}
|
||||
|
||||
stderr:
|
||||
{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let parsed: Value = serde_json::from_str(&stdout).expect("compact json stdout should parse");
|
||||
assert_eq!(
|
||||
parsed["message"],
|
||||
"Mock streaming says hello from the parity harness."
|
||||
);
|
||||
assert_eq!(parsed["compact"], true);
|
||||
assert_eq!(parsed["model"], "claude-sonnet-4-6");
|
||||
assert!(parsed["usage"].is_object());
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
fn run_claw(
|
||||
cwd: &std::path::Path,
|
||||
config_home: &std::path::Path,
|
||||
@@ -157,3 +275,21 @@ fn unique_temp_dir(label: &str) -> PathBuf {
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
fn strip_ansi_codes(input: &str) -> String {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut chars = input.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '\u{1b}' && matches!(chars.peek(), Some('[')) {
|
||||
chars.next();
|
||||
while let Some(next) = chars.next() {
|
||||
if ('@'..='~').contains(&next) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
output.push(ch);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
138
rust/crates/rusty-claude-cli/tests/compact_repl_panic.rs
Normal file
138
rust/crates/rusty-claude-cli/tests/compact_repl_panic.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[test]
|
||||
fn compact_slash_command_in_repl_does_not_start_nested_tokio_runtime() {
|
||||
// given
|
||||
let workspace = unique_temp_dir("compact-repl-panic");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
// when
|
||||
let output = run_claw_repl(&workspace, &config_home, &home, "/compact\n/exit\n");
|
||||
|
||||
// then
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"compact repl run should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
|
||||
assert!(
|
||||
!stderr.contains("Cannot start a runtime"),
|
||||
"stderr must not contain nested runtime panic: {stderr:?}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("panicked at"),
|
||||
"stderr must not contain panic output: {stderr:?}"
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let plain_stdout = strip_ansi_codes(&stdout);
|
||||
assert!(
|
||||
plain_stdout.contains("Compaction skipped")
|
||||
|| plain_stdout.contains("Result skipped")
|
||||
|| plain_stdout.contains("Result compacted"),
|
||||
"stdout should contain compact report output ({stdout:?})"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
fn run_claw_repl(
|
||||
cwd: &std::path::Path,
|
||||
config_home: &std::path::Path,
|
||||
home: &std::path::Path,
|
||||
stdin: &str,
|
||||
) -> Output {
|
||||
let mut command = python_pty_command(env!("CARGO_BIN_EXE_claw"));
|
||||
let mut child = command
|
||||
.current_dir(cwd)
|
||||
.env_clear()
|
||||
.env("ANTHROPIC_API_KEY", "test-compact-repl-key")
|
||||
.env("CLAW_CONFIG_HOME", config_home)
|
||||
.env("HOME", home)
|
||||
.env("NO_COLOR", "1")
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("claw should launch");
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.expect("stdin should be piped")
|
||||
.write_all(stdin.as_bytes())
|
||||
.expect("stdin should write");
|
||||
|
||||
child.wait_with_output().expect("claw should finish")
|
||||
}
|
||||
|
||||
fn python_pty_command(claw: &str) -> Command {
|
||||
let mut command = Command::new("python3");
|
||||
command.args([
|
||||
"-c",
|
||||
r#"
|
||||
import os
|
||||
import pty
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
claw = sys.argv[1]
|
||||
payload = sys.stdin.buffer.read()
|
||||
master, slave = pty.openpty()
|
||||
child = subprocess.Popen([claw], stdin=slave, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
os.close(slave)
|
||||
os.write(master, payload)
|
||||
stdout, stderr = child.communicate(timeout=30)
|
||||
os.close(master)
|
||||
sys.stdout.buffer.write(stdout)
|
||||
sys.stderr.buffer.write(stderr)
|
||||
raise SystemExit(child.returncode)
|
||||
"#,
|
||||
claw,
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
fn unique_temp_dir(label: &str) -> PathBuf {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_millis();
|
||||
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"claw-{label}-{}-{millis}-{counter}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
fn strip_ansi_codes(input: &str) -> String {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut chars = input.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '\u{1b}' && matches!(chars.peek(), Some('[')) {
|
||||
chars.next();
|
||||
for next in chars.by_ref() {
|
||||
if ('@'..='~').contains(&next) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
output.push(ch);
|
||||
}
|
||||
output
|
||||
}
|
||||
@@ -22,6 +22,42 @@ fn help_emits_json_when_requested() {
|
||||
.contains("Usage:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_help_emits_bounded_json_when_requested_384() {
|
||||
let root = unique_temp_dir("export-help-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let parsed = assert_json_command(&root, &["export", "--help", "--output-format", "json"]);
|
||||
assert_eq!(parsed["kind"], "help");
|
||||
assert_eq!(parsed["topic"], "export");
|
||||
assert_eq!(parsed["command"], "export");
|
||||
assert_eq!(
|
||||
parsed["usage"],
|
||||
"claw export [--session <id|latest>] [--output <path>] [--output-format <format>]"
|
||||
);
|
||||
assert_eq!(parsed["defaults"]["session"], "latest");
|
||||
assert!(parsed["options"].as_array().expect("options").len() >= 4);
|
||||
assert!(parsed.get("message").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_help_preserves_plaintext_in_text_mode_384() {
|
||||
let root = unique_temp_dir("export-help-text");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let output = run_claw(&root, &["export", "--help"], &[]);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
|
||||
assert!(stdout.starts_with("Export\n"));
|
||||
assert!(stdout.contains("Usage claw export"));
|
||||
serde_json::from_str::<Value>(&stdout).expect_err("text help should remain plaintext");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_emits_json_when_requested() {
|
||||
let root = unique_temp_dir("version-json");
|
||||
@@ -30,6 +66,15 @@ fn version_emits_json_when_requested() {
|
||||
let parsed = assert_json_command(&root, &["--output-format", "json", "version"]);
|
||||
assert_eq!(parsed["kind"], "version");
|
||||
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
|
||||
// Provenance fields must be present for binary identification (#507).
|
||||
assert!(
|
||||
parsed["build_date"].is_string(),
|
||||
"build_date must be a string in version JSON"
|
||||
);
|
||||
assert!(
|
||||
parsed["executable_path"].is_string(),
|
||||
"executable_path must be a string in version JSON so callers can identify which binary is running"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -46,6 +91,32 @@ fn status_and_sandbox_emit_json_when_requested() {
|
||||
assert!(sandbox["filesystem_mode"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_json_surfaces_permission_mode_override_for_security_audit() {
|
||||
let root = unique_temp_dir("status-json-permission-mode");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let parsed = assert_json_command(
|
||||
&root,
|
||||
&[
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
"--output-format",
|
||||
"json",
|
||||
"status",
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(parsed["kind"], "status");
|
||||
assert_eq!(parsed["permission_mode"], "read-only");
|
||||
assert!(
|
||||
parsed["workspace"]["cwd"].as_str().is_some(),
|
||||
"status JSON should retain workspace context with permission mode"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_guidance_emits_json_when_requested() {
|
||||
let root = unique_temp_dir("acp-json");
|
||||
@@ -105,6 +176,18 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
||||
let skills = assert_json_command(&root, &["--output-format", "json", "skills"]);
|
||||
assert_eq!(skills["kind"], "skills");
|
||||
assert_eq!(skills["action"], "list");
|
||||
|
||||
let plugins = assert_json_command(&root, &["--output-format", "json", "plugins"]);
|
||||
assert_eq!(plugins["kind"], "plugin");
|
||||
assert_eq!(plugins["action"], "list");
|
||||
assert!(
|
||||
plugins["reload_runtime"].is_boolean(),
|
||||
"plugins reload_runtime should be a boolean"
|
||||
);
|
||||
assert!(
|
||||
plugins["target"].is_null(),
|
||||
"plugins target should be null when no plugin is targeted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -227,7 +310,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
assert!(summary["failures"].as_u64().is_some());
|
||||
|
||||
let checks = doctor["checks"].as_array().expect("doctor checks");
|
||||
assert_eq!(checks.len(), 6);
|
||||
assert_eq!(checks.len(), 7);
|
||||
let check_names = checks
|
||||
.iter()
|
||||
.map(|check| {
|
||||
@@ -244,6 +327,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
"config",
|
||||
"install source",
|
||||
"workspace",
|
||||
"boot preflight",
|
||||
"sandbox",
|
||||
"system"
|
||||
]
|
||||
@@ -269,6 +353,14 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
assert!(workspace["cwd"].as_str().is_some());
|
||||
assert!(workspace["in_git_repo"].is_boolean());
|
||||
|
||||
let boot_preflight = checks
|
||||
.iter()
|
||||
.find(|check| check["name"] == "boot preflight")
|
||||
.expect("boot preflight check");
|
||||
assert!(boot_preflight["boot_preflight"]["repo"]["exists"].is_boolean());
|
||||
assert!(boot_preflight["boot_preflight"]["mcp_startup"]["eligible"].is_boolean());
|
||||
assert!(boot_preflight["boot_preflight"]["required_binaries"].is_array());
|
||||
|
||||
let sandbox = checks
|
||||
.iter()
|
||||
.find(|check| check["name"] == "sandbox")
|
||||
@@ -348,6 +440,62 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
|
||||
assert_eq!(skills["action"], "list");
|
||||
assert!(skills["summary"]["total"].is_number());
|
||||
assert!(skills["skills"].is_array());
|
||||
|
||||
let agents = assert_json_command_with_env(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 session path"),
|
||||
"/agents",
|
||||
],
|
||||
&[
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("utf8 config home"),
|
||||
),
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
],
|
||||
);
|
||||
assert_eq!(agents["kind"], "agents");
|
||||
assert_eq!(agents["action"], "list");
|
||||
assert!(
|
||||
agents["agents"].is_array(),
|
||||
"agents field must be a JSON array"
|
||||
);
|
||||
assert!(
|
||||
agents["count"].is_number(),
|
||||
"count must be a number, not a text render"
|
||||
);
|
||||
|
||||
let plugins = assert_json_command_with_env(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 session path"),
|
||||
"/plugins",
|
||||
],
|
||||
&[
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("utf8 config home"),
|
||||
),
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
],
|
||||
);
|
||||
assert_eq!(plugins["kind"], "plugin");
|
||||
assert_eq!(plugins["action"], "list");
|
||||
assert!(
|
||||
plugins["reload_runtime"].is_boolean(),
|
||||
"plugins reload_runtime should be a boolean"
|
||||
);
|
||||
assert!(
|
||||
plugins["target"].is_null(),
|
||||
"plugins target should be null when no plugin is targeted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -384,6 +532,44 @@ fn resumed_version_and_init_emit_structured_json_when_requested() {
|
||||
assert!(root.join("CLAUDE.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_section_json_emits_section_and_value() {
|
||||
let root = unique_temp_dir("config-section-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
// Without a section: should return base envelope (no section field).
|
||||
let base = assert_json_command(&root, &["--output-format", "json", "config"]);
|
||||
assert_eq!(base["kind"], "config");
|
||||
assert!(base["loaded_files"].is_number());
|
||||
assert!(base["merged_keys"].is_number());
|
||||
assert!(
|
||||
base.get("section").is_none(),
|
||||
"no section field without section arg"
|
||||
);
|
||||
|
||||
// With a known section: should add section + section_value fields.
|
||||
for section in &["model", "env", "hooks", "plugins"] {
|
||||
let result = assert_json_command(&root, &["--output-format", "json", "config", section]);
|
||||
assert_eq!(result["kind"], "config", "section={section}");
|
||||
assert_eq!(
|
||||
result["section"].as_str(),
|
||||
Some(*section),
|
||||
"section field must match requested section, got {result:?}"
|
||||
);
|
||||
assert!(
|
||||
result.get("section_value").is_some(),
|
||||
"section_value field must be present for section={section}"
|
||||
);
|
||||
}
|
||||
|
||||
// With an unsupported section: should return ok:false + error field.
|
||||
let bad = assert_json_command(&root, &["--output-format", "json", "config", "unknown"]);
|
||||
assert_eq!(bad["kind"], "config");
|
||||
assert_eq!(bad["ok"], false);
|
||||
assert!(bad["error"].as_str().is_some());
|
||||
assert!(bad["section"].as_str().is_some());
|
||||
}
|
||||
|
||||
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
|
||||
assert_json_command_with_env(current_dir, args, &[])
|
||||
}
|
||||
|
||||
@@ -180,6 +180,8 @@ fn resume_latest_restores_the_most_recent_managed_session() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("resume-latest");
|
||||
let project_dir = temp_dir.join("project");
|
||||
fs::create_dir_all(&project_dir).expect("project dir should exist");
|
||||
let project_dir = fs::canonicalize(&project_dir).unwrap_or(project_dir);
|
||||
let store = runtime::SessionStore::from_cwd(&project_dir).expect("session store should build");
|
||||
let older_path = store.create_handle("session-older").path;
|
||||
let newer_path = store.create_handle("session-newer").path;
|
||||
@@ -225,6 +227,8 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("resume-status-json");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
let config_home = temp_dir.join("config-home");
|
||||
fs::create_dir_all(&config_home).expect("isolated config home should exist");
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
|
||||
let mut session = workspace_session(&temp_dir);
|
||||
@@ -236,7 +240,9 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
||||
.expect("session should persist");
|
||||
|
||||
// when
|
||||
let output = run_claw(
|
||||
// Use an isolated CLAW_CONFIG_HOME so ~/.claw/settings.json is not loaded,
|
||||
// which would cause loaded_config_files to be non-zero (#65).
|
||||
let output = run_claw_with_env(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--output-format",
|
||||
@@ -245,6 +251,7 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/status",
|
||||
],
|
||||
&[("CLAW_CONFIG_HOME", config_home.to_str().expect("utf8 path"))],
|
||||
);
|
||||
|
||||
// then
|
||||
|
||||
@@ -56,6 +56,7 @@ pub(crate) fn detect_lane_completion(
|
||||
Some(LaneContext {
|
||||
lane_id: output.agent_id.clone(),
|
||||
green_level: 3, // Workspace green
|
||||
green_contract_satisfied: true,
|
||||
branch_freshness: std::time::Duration::from_secs(0),
|
||||
blocker: LaneBlocker::None,
|
||||
review_status: ReviewStatus::Approved,
|
||||
@@ -165,6 +166,7 @@ mod tests {
|
||||
let context = LaneContext {
|
||||
lane_id: "completed-lane".to_string(),
|
||||
green_level: 3,
|
||||
green_contract_satisfied: true,
|
||||
branch_freshness: std::time::Duration::from_secs(0),
|
||||
blocker: LaneBlocker::None,
|
||||
review_status: ReviewStatus::Approved,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
205
rust/crates/tools/tests/path_scope_enforcement.rs
Normal file
205
rust/crates/tools/tests/path_scope_enforcement.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
use runtime::{permission_enforcer::PermissionEnforcer, PermissionMode, PermissionPolicy};
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tools::{mvp_tool_specs, GlobalToolRegistry};
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn temp_path(name: &str) -> PathBuf {
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("time")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("claw-path-scope-{unique}-{name}"))
|
||||
}
|
||||
|
||||
fn workspace_write_registry() -> GlobalToolRegistry {
|
||||
let policy = mvp_tool_specs().into_iter().fold(
|
||||
PermissionPolicy::new(PermissionMode::WorkspaceWrite),
|
||||
|policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission),
|
||||
);
|
||||
GlobalToolRegistry::builtin().with_enforcer(PermissionEnforcer::new(policy))
|
||||
}
|
||||
|
||||
fn run_bash(command: &str) -> Result<String, String> {
|
||||
workspace_write_registry().execute("bash", &json!({ "command": command }))
|
||||
}
|
||||
|
||||
fn run_powershell(command: &str) -> Result<String, String> {
|
||||
workspace_write_registry().execute("PowerShell", &json!({ "command": command }))
|
||||
}
|
||||
|
||||
fn run_read_file(path: &Path) -> Result<String, String> {
|
||||
workspace_write_registry().execute("read_file", &json!({ "path": path.display().to_string() }))
|
||||
}
|
||||
|
||||
fn assert_permission_denied(result: Result<String, String>, case_name: &str) {
|
||||
let err = result
|
||||
.unwrap_err_or_else(|ok| panic!("{case_name} should be denied before execution, got {ok}"));
|
||||
assert!(
|
||||
(err.contains("requires danger-full-access permission")
|
||||
|| err.contains("requires \'danger-full-access\' permission"))
|
||||
|| err.contains("current mode is workspace-write")
|
||||
|| err.contains("escapes workspace"),
|
||||
"{case_name} should fail in permission enforcement, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
trait UnwrapErrOrElse<T, E> {
|
||||
fn unwrap_err_or_else<F: FnOnce(T) -> E>(self, op: F) -> E;
|
||||
}
|
||||
|
||||
impl<T, E> UnwrapErrOrElse<T, E> for Result<T, E> {
|
||||
fn unwrap_err_or_else<F: FnOnce(T) -> E>(self, op: F) -> E {
|
||||
match self {
|
||||
Ok(value) => op(value),
|
||||
Err(error) => error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_cwd<T>(cwd: &Path, f: impl FnOnce() -> T) -> T {
|
||||
let previous = std::env::current_dir().expect("current dir");
|
||||
std::env::set_current_dir(cwd).expect("set cwd");
|
||||
let result = f();
|
||||
std::env::set_current_dir(previous).expect("restore cwd");
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_paths_allow_workspace_file_and_deny_absolute_outside_file() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let root = temp_path("direct");
|
||||
fs::create_dir_all(root.join("src")).expect("create workspace");
|
||||
fs::write(root.join("src/lib.rs"), "workspace\n").expect("write workspace file");
|
||||
let outside = temp_path("direct-outside.txt");
|
||||
fs::write(&outside, "secret\n").expect("write outside file");
|
||||
|
||||
with_cwd(&root, || {
|
||||
let allowed = run_bash("cat src/lib.rs").expect("workspace-relative read should execute");
|
||||
assert!(allowed.contains("workspace"));
|
||||
assert_permission_denied(
|
||||
run_bash(&format!("cat {}", outside.display())),
|
||||
"absolute outside file",
|
||||
);
|
||||
});
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
let _ = fs::remove_file(outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_tool_direct_outside_path_is_denied_before_reading() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let root = temp_path("file-tool-direct");
|
||||
fs::create_dir_all(&root).expect("create workspace");
|
||||
let outside = temp_path("file-tool-secret.txt");
|
||||
fs::write(&outside, "secret\n").expect("write outside file");
|
||||
|
||||
with_cwd(&root, || {
|
||||
assert_permission_denied(run_read_file(&outside), "read_file outside workspace");
|
||||
});
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
let _ = fs::remove_file(outside);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_resolving_outside_workspace_is_denied_before_execution() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let root = temp_path("symlink");
|
||||
fs::create_dir_all(&root).expect("create workspace");
|
||||
let outside = temp_path("symlink-secret.txt");
|
||||
fs::write(&outside, "secret\n").expect("write outside file");
|
||||
std::os::unix::fs::symlink(&outside, root.join("secret-link")).expect("create symlink");
|
||||
|
||||
with_cwd(&root, || {
|
||||
assert_permission_denied(run_bash("cat secret-link"), "outside symlink");
|
||||
});
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
let _ = fs::remove_file(outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_expansion_and_glob_parent_traversal_are_denied_before_execution() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let root = temp_path("expansion");
|
||||
fs::create_dir_all(&root).expect("create workspace");
|
||||
|
||||
with_cwd(&root, || {
|
||||
for (name, command) in [
|
||||
("parent glob", "ls ../*"),
|
||||
("PWD parent expansion", "cat $PWD/../secret.txt"),
|
||||
("braced PWD parent expansion", "cat ${PWD}/../secret.txt"),
|
||||
(
|
||||
"command substitution parent expansion",
|
||||
"cat $(pwd)/../secret.txt",
|
||||
),
|
||||
] {
|
||||
assert_permission_denied(run_bash(command), name);
|
||||
}
|
||||
});
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_worktree_paths_are_allowed_but_parent_escape_is_denied() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let root = temp_path("worktree");
|
||||
let worktree = root.join("main").join("linked-worktree");
|
||||
fs::create_dir_all(worktree.join("src")).expect("create worktree");
|
||||
fs::write(worktree.join("src/lib.rs"), "worktree\n").expect("write worktree file");
|
||||
|
||||
with_cwd(&worktree, || {
|
||||
let allowed =
|
||||
run_bash("cat src/lib.rs").expect("nested worktree-relative read should execute");
|
||||
assert!(allowed.contains("worktree"));
|
||||
assert_permission_denied(run_bash("cat ../../outside.txt"), "worktree parent escape");
|
||||
});
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_style_absolute_paths_are_denied_before_execution() {
|
||||
for (name, command) in [
|
||||
(
|
||||
"windows drive backslash",
|
||||
r"cat C:\Users\attacker\secret.txt",
|
||||
),
|
||||
("windows drive slash", r"cat C:/Users/attacker/secret.txt"),
|
||||
] {
|
||||
assert_permission_denied(run_bash(command), name);
|
||||
}
|
||||
|
||||
for (name, command) in [
|
||||
(
|
||||
"powershell windows drive backslash",
|
||||
r"Get-Content -Path C:\Users\attacker\secret.txt",
|
||||
),
|
||||
(
|
||||
"powershell windows drive slash",
|
||||
r"Get-Content -Path C:/Users/attacker/secret.txt",
|
||||
),
|
||||
] {
|
||||
assert_permission_denied(run_powershell(command), name);
|
||||
}
|
||||
}
|
||||
54
scripts/cc2_board.py
Executable file
54
scripts/cc2_board.py
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Canonical CC2 board command wrapper.
|
||||
|
||||
This script intentionally delegates to the richer G001 board generator,
|
||||
validator, and Markdown renderer so all entrypoints enforce the same schema.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run(cmd: list[str], cwd: Path) -> int:
|
||||
return subprocess.run(cmd, cwd=str(cwd)).returncode
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("command", choices=["generate", "validate"])
|
||||
parser.add_argument("--repo-root", type=Path, default=Path.cwd(), help="repository root containing ROADMAP.md")
|
||||
parser.add_argument("--context-root", type=Path, default=None, help="accepted for compatibility; source .omx is auto-detected by the generator")
|
||||
parser.add_argument("--board-json", default=".omx/cc2/board.json")
|
||||
parser.add_argument("--board-md", default=".omx/cc2/board.md")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
repo_root = args.repo_root.resolve()
|
||||
board_json = repo_root / args.board_json
|
||||
board_md = repo_root / args.board_md
|
||||
generator = repo_root / "scripts" / "generate_cc2_board.py"
|
||||
validator = repo_root / "scripts" / "validate_cc2_board.py"
|
||||
renderer = repo_root / ".omx" / "cc2" / "render_board_md.py"
|
||||
|
||||
if args.command == "generate":
|
||||
rc = run([sys.executable, str(generator), "--repo-root", str(repo_root), "--out-dir", str(board_json.parent)], repo_root)
|
||||
if rc:
|
||||
return rc
|
||||
return run([sys.executable, str(renderer), str(board_json), str(board_md)], repo_root)
|
||||
|
||||
checks = [
|
||||
[sys.executable, str(validator), "--repo-root", str(repo_root), "--board", str(board_json)],
|
||||
[sys.executable, str(renderer), str(board_json), str(board_md), "--check"],
|
||||
]
|
||||
for cmd in checks:
|
||||
rc = run(cmd, repo_root)
|
||||
if rc:
|
||||
return rc
|
||||
print(f"CC2 board validation PASS: {board_json} and {board_md} are canonical and in sync")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
68
scripts/dogfood-build.sh
Executable file
68
scripts/dogfood-build.sh
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# dogfood-build.sh — Build claw from current checkout and verify provenance.
|
||||
#
|
||||
# Injects GIT_SHA at build time so version JSON is non-null.
|
||||
# Suppresses Cargo compile noise on stderr.
|
||||
# Prints the verified binary path on success. Use as:
|
||||
#
|
||||
# CLAW=$(bash scripts/dogfood-build.sh)
|
||||
#
|
||||
# Then dogfood with config isolation (avoids real user config bleeding in):
|
||||
#
|
||||
# CLAW_CONFIG_HOME=$(mktemp -d) $CLAW plugins list --output-format json
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
RUST_DIR="$REPO_ROOT/rust"
|
||||
BINARY="$RUST_DIR/target/debug/claw"
|
||||
EXPECTED_SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD)"
|
||||
|
||||
echo "▶ Building claw from $REPO_ROOT" >&2
|
||||
echo " Commit: $(git -C "$REPO_ROOT" log --oneline -1)" >&2
|
||||
|
||||
# Inject GIT_SHA so version JSON returns a non-null sha.
|
||||
# Redirect cargo stderr to /dev/null to suppress compile noise;
|
||||
# on build failure cargo exits non-zero and set -e aborts.
|
||||
if ! GIT_SHA="$EXPECTED_SHA" cargo build \
|
||||
--manifest-path "$RUST_DIR/Cargo.toml" \
|
||||
-p rusty-claude-cli -q 2>/dev/null; then
|
||||
# Re-run with visible output so the user sees the error
|
||||
echo "✗ Build failed — rerunning with output:" >&2
|
||||
GIT_SHA="$EXPECTED_SHA" cargo build \
|
||||
--manifest-path "$RUST_DIR/Cargo.toml" \
|
||||
-p rusty-claude-cli 2>&1 | sed 's/^/ /' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -x "$BINARY" ]]; then
|
||||
echo "✗ Binary not found at $BINARY" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BINARY_SHA=$("$BINARY" version --output-format json 2>/dev/null \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('git_sha') or 'null')" 2>/dev/null \
|
||||
|| echo "null")
|
||||
|
||||
if [[ "$BINARY_SHA" == "null" || -z "$BINARY_SHA" ]]; then
|
||||
echo "✗ Provenance check failed: binary reports git_sha: null" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$BINARY_SHA" != "$EXPECTED_SHA" ]]; then
|
||||
echo "✗ Provenance mismatch: binary=$BINARY_SHA, HEAD=$EXPECTED_SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Binary verified: $BINARY_SHA == HEAD" >&2
|
||||
echo "" >&2
|
||||
echo " export CLAW=$BINARY" >&2
|
||||
echo "" >&2
|
||||
echo " Dogfood with isolated config (no real user config on stderr):" >&2
|
||||
echo " CLAW_ISOLATED=\$(mktemp -d)" >&2
|
||||
echo " CLAW_CONFIG_HOME=\$CLAW_ISOLATED \$CLAW plugins list --output-format json" >&2
|
||||
echo " rm -rf \$CLAW_ISOLATED" >&2
|
||||
echo "" >&2
|
||||
echo " cargo run overhead: ~1s/invocation vs 7ms for pre-built binary." >&2
|
||||
echo " Prefer pre-built binary (\$CLAW) for dogfood loops." >&2
|
||||
echo "$BINARY"
|
||||
7
scripts/fmt.sh
Executable file
7
scripts/fmt.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_ROOT/rust"
|
||||
exec cargo fmt "$@"
|
||||
525
scripts/generate_cc2_board.py
Executable file
525
scripts/generate_cc2_board.py
Executable file
@@ -0,0 +1,525 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the canonical Claw Code 2.0 execution board from frozen roadmap evidence."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REQUIRED_ITEM_FIELDS = [
|
||||
"id",
|
||||
"title",
|
||||
"source_anchor",
|
||||
"source_type",
|
||||
"release_bucket",
|
||||
"status",
|
||||
"dependencies",
|
||||
"verification_required",
|
||||
"deferral_rationale",
|
||||
]
|
||||
STATUSES = {
|
||||
"context",
|
||||
"active",
|
||||
"open",
|
||||
"done_verify",
|
||||
"stale_done",
|
||||
"superseded",
|
||||
"deferred_with_rationale",
|
||||
"rejected_not_claw",
|
||||
}
|
||||
RELEASE_BUCKETS = {
|
||||
"alpha_blocker",
|
||||
"beta_adoption",
|
||||
"ga_ecosystem",
|
||||
"post_2_0_research",
|
||||
"rejected_not_claw",
|
||||
"context",
|
||||
"2.x_intake",
|
||||
}
|
||||
|
||||
STRUCTURAL_HEADINGS = {
|
||||
"Clawable Coding Harness Roadmap",
|
||||
"Goal",
|
||||
'Definition of "clawable"',
|
||||
"Current Pain Points",
|
||||
"Product Principles",
|
||||
"Roadmap",
|
||||
"Immediate Backlog (from current real pain)",
|
||||
"Deployment Architecture Gap (filed from dogfood 2026-04-08)",
|
||||
"Startup Friction Gap: No Default trusted_roots in Settings (filed 2026-04-08)",
|
||||
"Observability Transport Decision (filed 2026-04-08)",
|
||||
"Provider Routing: Model-Name Prefix Must Win Over Env-Var Presence (fixed 2026-04-08, `0530c50`)",
|
||||
}
|
||||
|
||||
CATEGORY_KEYWORDS = [
|
||||
("security", ["security", "sandbox", "permission", "trust", "approval-token", "denied"]),
|
||||
("windows_install", ["windows", "install", "path", "release", "binary", "container"]),
|
||||
("provider", ["provider", "model", "openai", "anthropic", "ollama", "llama", "vllm", "credential"]),
|
||||
("sessions", ["session", "resume", "compact", "context-window", "thread"]),
|
||||
("docs_license", ["docs", "readme", "usage", "license", "help", "onboarding"]),
|
||||
("ide_acp", ["zed", "acp", "editor", "daemon"]),
|
||||
("plugin_mcp", ["plugin", "mcp", "marketplace", "server"]),
|
||||
("event_report", ["event", "report", "schema", "projection", "redaction", "clawhip", "lane"]),
|
||||
("branch_recovery", ["branch", "stale", "recovery", "green", "flake"]),
|
||||
("boot", ["boot", "worker", "startup", "ready", "prompt"]),
|
||||
("task_policy", ["task", "policy", "claw-native", "dashboard", "lane board"]),
|
||||
("ux_tui", ["tui", "statusline", "keymap", "clickable", "copy", "paste"]),
|
||||
("anti_slop", ["spam", "slop", "issue hygiene", "bot"]),
|
||||
]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoadmapRecord:
|
||||
line: int
|
||||
level: int
|
||||
title: str
|
||||
path: str
|
||||
source_type: str
|
||||
ordinal: int | None = None
|
||||
|
||||
|
||||
def sha256_prefix(path: Path, length: int = 16) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()[:length]
|
||||
|
||||
|
||||
def slugify(text: str, limit: int = 54) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
||||
return slug[:limit].strip("-") or "item"
|
||||
|
||||
|
||||
def find_source_omx(repo_root: Path) -> Path:
|
||||
candidates = []
|
||||
env = None
|
||||
try:
|
||||
import os
|
||||
env = os.environ.get("CC2_SOURCE_OMX")
|
||||
except Exception:
|
||||
env = None
|
||||
if env:
|
||||
candidates.append(Path(env).expanduser())
|
||||
candidates.append(repo_root / ".omx")
|
||||
candidates.extend(parent / ".omx" for parent in repo_root.parents)
|
||||
for candidate in candidates:
|
||||
if (candidate / "plans" / "claw-code-2-0-adaptive-plan.md").exists() and (candidate / "research").exists():
|
||||
return candidate
|
||||
raise FileNotFoundError("could not locate source .omx with plans/claw-code-2-0-adaptive-plan.md and research/")
|
||||
|
||||
|
||||
def parse_roadmap(path: Path) -> tuple[list[RoadmapRecord], list[RoadmapRecord]]:
|
||||
headings: list[RoadmapRecord] = []
|
||||
actions: list[RoadmapRecord] = []
|
||||
stack: list[tuple[str, int, int]] = []
|
||||
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
heading = re.match(r"^(#{1,6})\s+(.*?)(?:\s+#+)?\s*$", line)
|
||||
if heading:
|
||||
level = len(heading.group(1))
|
||||
title = heading.group(2).strip()
|
||||
stack = [entry for entry in stack if entry[1] < level] + [(title, level, line_no)]
|
||||
headings.append(RoadmapRecord(line_no, level, title, " > ".join(entry[0] for entry in stack), "roadmap_heading"))
|
||||
continue
|
||||
ordered = re.match(r"^(\s*)(\d+)\.\s+(.+?)\s*$", line)
|
||||
if ordered and len(ordered.group(1)) <= 4:
|
||||
title = ordered.group(3).strip()
|
||||
if len(title) > 10:
|
||||
actions.append(
|
||||
RoadmapRecord(
|
||||
line_no,
|
||||
len(stack[-1][0]) if stack else 0,
|
||||
title,
|
||||
" > ".join(entry[0] for entry in stack),
|
||||
"roadmap_action",
|
||||
int(ordered.group(2)),
|
||||
)
|
||||
)
|
||||
return headings, actions
|
||||
|
||||
|
||||
def category_for(text: str) -> str:
|
||||
lower = text.lower()
|
||||
for category, needles in CATEGORY_KEYWORDS:
|
||||
if any(needle in lower for needle in needles):
|
||||
return category
|
||||
return "governance"
|
||||
|
||||
|
||||
def stream_for(record: RoadmapRecord) -> str:
|
||||
title = record.title.lower()
|
||||
path = record.path.lower()
|
||||
combined = f"{path} {title}"
|
||||
if "phase 1" in combined or category_for(combined) == "boot":
|
||||
return "stream_1_worker_boot_session_control"
|
||||
if "phase 2" in combined or category_for(combined) == "event_report":
|
||||
return "stream_2_event_reporting_contracts"
|
||||
if "phase 3" in combined or category_for(combined) == "branch_recovery":
|
||||
return "stream_3_branch_test_recovery"
|
||||
if "phase 4" in combined or category_for(combined) == "task_policy":
|
||||
return "stream_4_claws_first_execution"
|
||||
if "phase 5" in combined or category_for(combined) == "plugin_mcp":
|
||||
return "stream_5_plugin_mcp_lifecycle"
|
||||
if any(k in combined for k in ["windows", "install", "provider", "docs", "license", "session hygiene", "compact"]):
|
||||
return "adoption_overlay"
|
||||
if any(k in combined for k in ["zed", "acp", "desktop", "marketplace", "package"]):
|
||||
return "parity_overlay"
|
||||
return "stream_0_governance"
|
||||
|
||||
|
||||
def release_bucket_for(record: RoadmapRecord, status: str) -> str:
|
||||
combined = f"{record.path} {record.title}".lower()
|
||||
category = category_for(combined)
|
||||
if status == "context":
|
||||
return "context"
|
||||
if status == "rejected_not_claw":
|
||||
return "rejected_not_claw"
|
||||
if any(k in combined for k in ["phase 1", "phase 2", "phase 3", "phase 4", "p0", "p1", "security", "sandbox", "trust", "worker", "event", "branch freshness"]):
|
||||
return "alpha_blocker"
|
||||
if category in {"windows_install", "provider", "sessions", "docs_license", "anti_slop"}:
|
||||
return "beta_adoption"
|
||||
if category in {"plugin_mcp", "ide_acp", "ux_tui"}:
|
||||
return "ga_ecosystem"
|
||||
if any(k in combined for k in ["desktop", "share", "cloud", "research", "post-2.0", "future"]):
|
||||
return "post_2_0_research"
|
||||
if "pinpoint" in combined:
|
||||
return "alpha_blocker"
|
||||
return "beta_adoption"
|
||||
|
||||
|
||||
def status_for(record: RoadmapRecord) -> str:
|
||||
title = record.title
|
||||
combined = f"{record.path} {title}".lower()
|
||||
if record.source_type == "roadmap_heading" and (record.level <= 2 or title in STRUCTURAL_HEADINGS):
|
||||
# Phase headings are active work containers; other h1/h2 prose headings are context unless fixed/deferred wording says otherwise.
|
||||
if title.startswith("Phase "):
|
||||
return "active"
|
||||
if "pinpoint" not in title.lower() and not any(word in combined for word in ["gap", "routing"]):
|
||||
return "context"
|
||||
if any(word in combined for word in ["rejected_not_claw", "not claw", "outside claw"]):
|
||||
return "rejected_not_claw"
|
||||
if "superseded" in combined:
|
||||
return "superseded"
|
||||
if "deferred" in combined or "post-2.0" in combined or "post_2_0" in combined:
|
||||
return "deferred_with_rationale"
|
||||
if any(word in combined for word in ["done", "implemented", "fixed", "verified", "re-verified", "landed", "green"]):
|
||||
if any(word in combined for word in ["stale", "old filing", "original filing below", "no longer reproduces"]):
|
||||
return "stale_done"
|
||||
return "done_verify"
|
||||
if title.lower().startswith(("evidence for", "trace path", "actual root cause", "meta-lesson")):
|
||||
return "context"
|
||||
return "open" if "pinpoint" in combined or record.source_type == "roadmap_action" else "active"
|
||||
|
||||
|
||||
def deferral_for(record: RoadmapRecord, status: str) -> str:
|
||||
if status == "deferred_with_rationale":
|
||||
return "Deferred by roadmap/approved plan until prerequisite contracts or post-2.0 research admission gates are satisfied."
|
||||
if status == "rejected_not_claw":
|
||||
return "Rejected because the source describes clone-only breadth or behavior outside Claw's machine-truth/clawable-harness identity."
|
||||
if status == "superseded":
|
||||
return "Superseded by a newer roadmap entry or canonical Rust/control-plane contract; keep only for audit traceability."
|
||||
if status == "stale_done":
|
||||
return "Marked done in roadmap but needs freshness re-verification before being used as release evidence."
|
||||
return ""
|
||||
|
||||
|
||||
def verification_for(record: RoadmapRecord, status: str) -> str:
|
||||
if status == "context":
|
||||
return "none_context_only"
|
||||
if status in {"done_verify", "stale_done"}:
|
||||
return "verify_existing_evidence_and_regression_guard"
|
||||
cat = category_for(f"{record.path} {record.title}")
|
||||
if cat == "docs_license":
|
||||
return "docs_snapshot_or_help_output_check"
|
||||
if cat == "windows_install":
|
||||
return "install_matrix_or_cross_platform_smoke"
|
||||
if cat == "provider":
|
||||
return "provider_routing_contract_test"
|
||||
if cat == "plugin_mcp":
|
||||
return "plugin_mcp_lifecycle_contract_test"
|
||||
if cat == "event_report":
|
||||
return "schema_golden_fixture_or_consumer_contract_test"
|
||||
if cat == "branch_recovery":
|
||||
return "git_fixture_or_recovery_recipe_test"
|
||||
if cat == "boot":
|
||||
return "worker_boot_state_machine_or_cli_json_contract_test"
|
||||
return "targeted_regression_or_acceptance_test_required"
|
||||
|
||||
|
||||
def dependencies_for(record: RoadmapRecord, status: str) -> list[str]:
|
||||
combined = f"{record.path} {record.title}".lower()
|
||||
deps: list[str] = []
|
||||
if status == "context":
|
||||
return deps
|
||||
if "phase 2" in combined or category_for(combined) == "event_report":
|
||||
deps.append("stream_1_worker_boot_session_control")
|
||||
if "phase 3" in combined or category_for(combined) == "branch_recovery":
|
||||
deps.append("stream_2_event_reporting_contracts")
|
||||
if "phase 4" in combined or category_for(combined) == "task_policy":
|
||||
deps.append("stream_2_event_reporting_contracts")
|
||||
if "phase 5" in combined or category_for(combined) == "plugin_mcp":
|
||||
deps.append("stream_1_worker_boot_session_control")
|
||||
if any(k in combined for k in ["zed", "acp", "desktop", "marketplace"]):
|
||||
deps.append("stable_alpha_contracts")
|
||||
if any(k in combined for k in ["provider", "install", "windows", "docs", "license"]):
|
||||
deps.append("adoption_overlay_triage")
|
||||
return sorted(set(deps))
|
||||
|
||||
|
||||
def roadmap_item(record: RoadmapRecord, index: int) -> dict[str, Any]:
|
||||
status = status_for(record)
|
||||
item_id = f"CC2-RM-{'H' if record.source_type == 'roadmap_heading' else 'A'}{index:04d}-{slugify(record.title, 40)}"
|
||||
bucket = release_bucket_for(record, status)
|
||||
return {
|
||||
"id": item_id,
|
||||
"title": record.title,
|
||||
"source_anchor": f"ROADMAP.md:L{record.line}",
|
||||
"source_type": record.source_type,
|
||||
"source_path": "ROADMAP.md",
|
||||
"source_context": record.path,
|
||||
"source_line": record.line,
|
||||
"source_level": record.level if record.source_type == "roadmap_heading" else None,
|
||||
"source_ordinal": record.ordinal,
|
||||
"release_bucket": bucket,
|
||||
"lifecycle_status": status,
|
||||
"status": status,
|
||||
"category": category_for(f"{record.path} {record.title}"),
|
||||
"owner_lane": stream_for(record),
|
||||
"dependencies": dependencies_for(record, status),
|
||||
"verification_required": verification_for(record, status),
|
||||
"deferral_rationale": deferral_for(record, status),
|
||||
}
|
||||
|
||||
|
||||
def load_json(path: Path) -> Any:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def issue_item(issue: dict[str, Any], source_name: str, source_type: str, bucket: str) -> dict[str, Any]:
|
||||
title = issue.get("title") or f"Issue #{issue.get('number')}"
|
||||
number = issue.get("number")
|
||||
body = f"{title} {issue.get('body') or ''}"
|
||||
status = "open" if issue.get("state", "OPEN").lower() != "closed" else "done_verify"
|
||||
return {
|
||||
"id": f"CC2-ISSUE-{source_name.upper()}-{number}",
|
||||
"title": title,
|
||||
"source_anchor": f".omx/research/{source_name}.json#issue-{number}",
|
||||
"source_type": source_type,
|
||||
"source_path": f".omx/research/{source_name}.json",
|
||||
"issue_number": number,
|
||||
"issue_url": issue.get("url"),
|
||||
"release_bucket": bucket,
|
||||
"lifecycle_status": status,
|
||||
"status": status,
|
||||
"category": category_for(body),
|
||||
"owner_lane": stream_for(RoadmapRecord(0, 0, title, title, source_type)),
|
||||
"dependencies": ["roadmap_board_triage"],
|
||||
"verification_required": "issue_acceptance_repro_or_triage_decision",
|
||||
"deferral_rationale": "Latest issue intake is admitted only when it matches freeze/admission rules; otherwise remains 2.x_intake." if bucket == "2.x_intake" else "",
|
||||
}
|
||||
|
||||
|
||||
def repo_context_item(meta: dict[str, Any], source_name: str) -> dict[str, Any]:
|
||||
owner = meta.get("nameWithOwner", source_name)
|
||||
return {
|
||||
"id": f"CC2-PARITY-{source_name.upper()}-REPO-CONTEXT",
|
||||
"title": f"Parity source metadata: {owner}",
|
||||
"source_anchor": f".omx/research/{source_name}-repo.json",
|
||||
"source_type": "parity_repo_context",
|
||||
"source_path": f".omx/research/{source_name}-repo.json",
|
||||
"release_bucket": "context",
|
||||
"lifecycle_status": "context",
|
||||
"status": "context",
|
||||
"category": "governance",
|
||||
"owner_lane": "parity_overlay",
|
||||
"dependencies": [],
|
||||
"verification_required": "none_context_only",
|
||||
"deferral_rationale": "",
|
||||
"repo": {
|
||||
"nameWithOwner": owner,
|
||||
"url": meta.get("url"),
|
||||
"pushedAt": meta.get("pushedAt"),
|
||||
"latestRelease": meta.get("latestRelease"),
|
||||
"licenseInfo": meta.get("licenseInfo"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def summarize_counts(items: list[dict[str, Any]], key: str) -> dict[str, int]:
|
||||
out: dict[str, int] = {}
|
||||
for item in items:
|
||||
out[item[key]] = out.get(item[key], 0) + 1
|
||||
return dict(sorted(out.items()))
|
||||
|
||||
|
||||
def render_markdown(board: dict[str, Any]) -> str:
|
||||
lines = [
|
||||
"# Claw Code 2.0 Canonical Board",
|
||||
"",
|
||||
f"Generated: `{board['generated_at']}`",
|
||||
f"Roadmap SHA-256 prefix: `{board['sources']['roadmap']['sha256_prefix']}`",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
f"- Total items: **{len(board['items'])}**",
|
||||
f"- Roadmap headings covered: **{board['coverage']['roadmap_headings_total']} / {board['coverage']['roadmap_headings_mapped']}**",
|
||||
f"- Roadmap ordered actions covered: **{board['coverage']['roadmap_actions_total']} / {board['coverage']['roadmap_actions_mapped']}**",
|
||||
"",
|
||||
"### By lifecycle status",
|
||||
"",
|
||||
]
|
||||
for status, count in board["summary"]["by_status"].items():
|
||||
lines.append(f"- `{status}`: {count}")
|
||||
lines.extend(["", "### By release bucket", ""])
|
||||
for bucket, count in board["summary"]["by_release_bucket"].items():
|
||||
lines.append(f"- `{bucket}`: {count}")
|
||||
lines.extend(["", "## Board Items", ""])
|
||||
for item in board["items"]:
|
||||
deps = ", ".join(item.get("dependencies") or []) or "none"
|
||||
rationale = item.get("deferral_rationale") or ""
|
||||
lines.extend([
|
||||
f"### {item['id']}",
|
||||
f"- Title: {item['title']}",
|
||||
f"- Source: `{item['source_anchor']}` (`{item['source_type']}`)",
|
||||
f"- Bucket/status: `{item['release_bucket']}` / `{item['status']}`",
|
||||
f"- Category/lane: `{item.get('category')}` / `{item.get('owner_lane')}`",
|
||||
f"- Dependencies: {deps}",
|
||||
f"- Verification: `{item['verification_required']}`",
|
||||
f"- Deferral rationale: {rationale}",
|
||||
"",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def validate_board(board: dict[str, Any]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
seen = set()
|
||||
for index, item in enumerate(board.get("items", []), 1):
|
||||
missing = [field for field in REQUIRED_ITEM_FIELDS if field not in item]
|
||||
if missing:
|
||||
errors.append(f"item {index} missing fields: {missing}")
|
||||
if item.get("id") in seen:
|
||||
errors.append(f"duplicate id: {item.get('id')}")
|
||||
seen.add(item.get("id"))
|
||||
if item.get("status") not in STATUSES:
|
||||
errors.append(f"{item.get('id')} invalid status {item.get('status')}")
|
||||
if item.get("release_bucket") not in RELEASE_BUCKETS:
|
||||
errors.append(f"{item.get('id')} invalid release_bucket {item.get('release_bucket')}")
|
||||
if not isinstance(item.get("dependencies"), list):
|
||||
errors.append(f"{item.get('id')} dependencies must be list")
|
||||
coverage = board.get("coverage", {})
|
||||
if coverage.get("unmapped_roadmap_heading_lines"):
|
||||
errors.append(f"unmapped heading lines: {coverage['unmapped_roadmap_heading_lines']}")
|
||||
if coverage.get("duplicate_roadmap_heading_lines"):
|
||||
errors.append(f"duplicate heading lines: {coverage['duplicate_roadmap_heading_lines']}")
|
||||
if coverage.get("roadmap_headings_total") != coverage.get("roadmap_headings_mapped"):
|
||||
errors.append("roadmap heading total/mapped mismatch")
|
||||
return errors
|
||||
|
||||
|
||||
def build_board(repo_root: Path) -> dict[str, Any]:
|
||||
roadmap_path = repo_root / "ROADMAP.md"
|
||||
source_omx = find_source_omx(repo_root)
|
||||
research = source_omx / "research"
|
||||
plan_path = source_omx / "plans" / "claw-code-2-0-adaptive-plan.md"
|
||||
headings, actions = parse_roadmap(roadmap_path)
|
||||
items = [roadmap_item(record, i) for i, record in enumerate(headings, 1)]
|
||||
items.extend(roadmap_item(record, i) for i, record in enumerate(actions, 1))
|
||||
|
||||
latest_issues = load_json(research / "claw-open-latest.json")
|
||||
all_issues = load_json(research / "claw-issues.json")
|
||||
items.extend(issue_item(issue, "claw-open-latest", "latest_open_issue", "2.x_intake") for issue in latest_issues)
|
||||
# Include a small real-issue sample from the full freeze to keep the board tied to the larger issue manifest without exploding scope.
|
||||
for issue in all_issues[:50]:
|
||||
title_body = f"{issue.get('title','')} {issue.get('body','')}".lower()
|
||||
if any(k in title_body for k in ["security", "windows", "install", "provider", "model", "session", "license", "zed", "spam", "plugin"]):
|
||||
items.append(issue_item(issue, "claw-issues", "issue_theme", "beta_adoption"))
|
||||
for source_name in ["opencode", "codex"]:
|
||||
repo_meta = load_json(research / f"{source_name}-repo.json")
|
||||
items.append(repo_context_item(repo_meta, source_name))
|
||||
|
||||
heading_lines = [record.line for record in headings]
|
||||
mapped_heading_lines = [item["source_line"] for item in items if item.get("source_type") == "roadmap_heading"]
|
||||
duplicate_heading_lines = sorted(line for line in set(mapped_heading_lines) if mapped_heading_lines.count(line) != 1)
|
||||
unmapped_heading_lines = sorted(set(heading_lines) - set(mapped_heading_lines))
|
||||
|
||||
board = {
|
||||
"schema_version": "cc2.board.v1",
|
||||
"generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
||||
"generation_policy": {
|
||||
"ultragoal_mutation": "forbidden",
|
||||
"roadmap_coverage": "all markdown headings plus top-level ordered roadmap actions",
|
||||
"status_values": sorted(STATUSES),
|
||||
"release_buckets": sorted(RELEASE_BUCKETS),
|
||||
},
|
||||
"sources": {
|
||||
"roadmap": {
|
||||
"path": "ROADMAP.md",
|
||||
"sha256_prefix": sha256_prefix(roadmap_path),
|
||||
"heading_count": len(headings),
|
||||
"ordered_action_count": len(actions),
|
||||
},
|
||||
"approved_plan": {
|
||||
"path": ".omx/plans/claw-code-2-0-adaptive-plan.md",
|
||||
"sha256_prefix": sha256_prefix(plan_path),
|
||||
},
|
||||
"research": {
|
||||
"root": str(source_omx / "research"),
|
||||
"claw_open_latest_count": len(latest_issues),
|
||||
"claw_issues_count": len(all_issues),
|
||||
"opencode_repo": ".omx/research/opencode-repo.json",
|
||||
"codex_repo": ".omx/research/codex-repo.json",
|
||||
},
|
||||
},
|
||||
"coverage": {
|
||||
"roadmap_headings_total": len(headings),
|
||||
"roadmap_headings_mapped": len(mapped_heading_lines),
|
||||
"unmapped_roadmap_heading_lines": unmapped_heading_lines,
|
||||
"duplicate_roadmap_heading_lines": duplicate_heading_lines,
|
||||
"roadmap_actions_total": len(actions),
|
||||
"roadmap_actions_mapped": len([item for item in items if item.get("source_type") == "roadmap_action"]),
|
||||
},
|
||||
"summary": {},
|
||||
"items": items,
|
||||
}
|
||||
board["summary"] = {
|
||||
"by_status": summarize_counts(items, "status"),
|
||||
"by_release_bucket": summarize_counts(items, "release_bucket"),
|
||||
"by_source_type": summarize_counts(items, "source_type"),
|
||||
"by_owner_lane": summarize_counts(items, "owner_lane"),
|
||||
}
|
||||
errors = validate_board(board)
|
||||
if errors:
|
||||
raise SystemExit("board validation failed:\n" + "\n".join(errors))
|
||||
return board
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo-root", type=Path, default=Path.cwd())
|
||||
parser.add_argument("--out-dir", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
repo_root = args.repo_root.resolve()
|
||||
out_dir = args.out_dir or (repo_root / ".omx" / "cc2")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
board = build_board(repo_root)
|
||||
board_json = out_dir / "board.json"
|
||||
board_md = out_dir / "board.md"
|
||||
board_json.write_text(json.dumps(board, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
renderer = repo_root / ".omx" / "cc2" / "render_board_md.py"
|
||||
if renderer.exists():
|
||||
subprocess.run([sys.executable, str(renderer), str(board_json), str(board_md)], check=True, cwd=str(repo_root))
|
||||
else:
|
||||
board_md.write_text(render_markdown(board) + "\n", encoding="utf-8")
|
||||
|
||||
print(f"wrote {board_json}")
|
||||
print(f"wrote {board_md}")
|
||||
print(f"roadmap headings mapped: {board['coverage']['roadmap_headings_mapped']}/{board['coverage']['roadmap_headings_total']}")
|
||||
print(f"roadmap actions mapped: {board['coverage']['roadmap_actions_mapped']}/{board['coverage']['roadmap_actions_total']}")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
87
scripts/validate_cc2_board.py
Executable file
87
scripts/validate_cc2_board.py
Executable file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the generated Claw Code 2.0 board coverage and schema."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REQUIRED = {
|
||||
"id",
|
||||
"title",
|
||||
"source_anchor",
|
||||
"source_type",
|
||||
"release_bucket",
|
||||
"status",
|
||||
"dependencies",
|
||||
"verification_required",
|
||||
"deferral_rationale",
|
||||
}
|
||||
STATUSES = {
|
||||
"context",
|
||||
"active",
|
||||
"open",
|
||||
"done_verify",
|
||||
"stale_done",
|
||||
"superseded",
|
||||
"deferred_with_rationale",
|
||||
"rejected_not_claw",
|
||||
}
|
||||
|
||||
def roadmap_heading_lines(path: Path) -> list[int]:
|
||||
lines = []
|
||||
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
if re.match(r"^#{1,6}\s+", line):
|
||||
lines.append(line_no)
|
||||
return lines
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo-root", type=Path, default=Path.cwd())
|
||||
parser.add_argument("--board", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
repo_root = args.repo_root.resolve()
|
||||
board_path = args.board or (repo_root / ".omx" / "cc2" / "board.json")
|
||||
board = json.loads(board_path.read_text(encoding="utf-8"))
|
||||
errors: list[str] = []
|
||||
ids = set()
|
||||
for index, item in enumerate(board.get("items", []), 1):
|
||||
missing = REQUIRED - set(item)
|
||||
if missing:
|
||||
errors.append(f"item {index} missing required fields: {sorted(missing)}")
|
||||
if item.get("id") in ids:
|
||||
errors.append(f"duplicate id: {item.get('id')}")
|
||||
ids.add(item.get("id"))
|
||||
if item.get("status") not in STATUSES:
|
||||
errors.append(f"{item.get('id')} invalid status {item.get('status')}")
|
||||
if not isinstance(item.get("dependencies"), list):
|
||||
errors.append(f"{item.get('id')} dependencies must be list")
|
||||
expected = roadmap_heading_lines(repo_root / "ROADMAP.md")
|
||||
mapped = [item.get("source_line") for item in board.get("items", []) if item.get("source_type") == "roadmap_heading"]
|
||||
unmapped = sorted(set(expected) - set(mapped))
|
||||
duplicates = sorted(line for line in set(mapped) if mapped.count(line) != 1)
|
||||
if unmapped:
|
||||
errors.append(f"unmapped ROADMAP headings: {unmapped}")
|
||||
if duplicates:
|
||||
errors.append(f"duplicate ROADMAP heading mappings: {duplicates}")
|
||||
coverage = board.get("coverage", {})
|
||||
if coverage.get("roadmap_headings_total") != len(expected):
|
||||
errors.append("coverage roadmap_headings_total does not match ROADMAP.md")
|
||||
if coverage.get("roadmap_headings_mapped") != len(mapped):
|
||||
errors.append("coverage roadmap_headings_mapped does not match board items")
|
||||
if errors:
|
||||
print("FAIL cc2 board validation")
|
||||
for error in errors:
|
||||
print(f"- {error}")
|
||||
return 1
|
||||
print("PASS cc2 board validation")
|
||||
print(f"- board: {board_path}")
|
||||
print(f"- items: {len(board.get('items', []))}")
|
||||
print(f"- ROADMAP headings mapped: {len(mapped)}/{len(expected)}")
|
||||
print(f"- ROADMAP actions mapped: {coverage.get('roadmap_actions_mapped')}/{coverage.get('roadmap_actions_total')}")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -23,6 +23,7 @@ class PortingModule:
|
||||
class PermissionDenial:
|
||||
tool_name: str
|
||||
reason: str
|
||||
status: str = 'blocked'
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
150
src/path_scope.py
Normal file
150
src/path_scope.py
Normal file
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PureWindowsPath
|
||||
|
||||
_GLOB_META = set('*?[')
|
||||
_WINDOWS_DRIVE_RE = re.compile(r'^[A-Za-z]:[\\/]')
|
||||
_WINDOWS_UNC_RE = re.compile(r'^(?:\\\\|//)[^\\/]+[\\/][^\\/]+')
|
||||
_ENV_ASSIGNMENT_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*=')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathScopeDecision:
|
||||
allowed: bool
|
||||
reason: str
|
||||
candidate: str | None = None
|
||||
resolved: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspacePathScope:
|
||||
"""Validate tool/shell path operands against explicit workspace roots.
|
||||
|
||||
The policy is intentionally conservative for the Python port: any candidate
|
||||
path that resolves outside the configured roots is denied, including paths
|
||||
reached through symlinks or glob expansion. Windows drive/UNC paths are
|
||||
treated as out-of-scope on POSIX roots unless an allowed root is also a
|
||||
Windows-style root with the same prefix.
|
||||
"""
|
||||
|
||||
roots: tuple[Path, ...]
|
||||
|
||||
@classmethod
|
||||
def from_root(cls, root: str | Path) -> 'WorkspacePathScope':
|
||||
return cls.from_roots((root,))
|
||||
|
||||
@classmethod
|
||||
def from_roots(cls, roots: tuple[str | Path, ...] | list[str | Path]) -> 'WorkspacePathScope':
|
||||
resolved_roots = tuple(Path(root).expanduser().resolve(strict=False) for root in roots)
|
||||
if not resolved_roots:
|
||||
raise ValueError('at least one workspace root is required')
|
||||
return cls(resolved_roots)
|
||||
|
||||
def validate_payload(self, payload: str, cwd: str | Path | None = None) -> PathScopeDecision:
|
||||
cwd_path = Path(cwd).expanduser().resolve(strict=False) if cwd else self.roots[0]
|
||||
cwd_decision = self.validate_path(cwd_path)
|
||||
if not cwd_decision.allowed:
|
||||
return PathScopeDecision(False, f'cwd outside workspace scope: {cwd_path}', str(cwd_path), cwd_decision.resolved)
|
||||
for candidate in extract_path_candidates(payload):
|
||||
decision = self.validate_path(candidate, cwd_path)
|
||||
if not decision.allowed:
|
||||
return decision
|
||||
return PathScopeDecision(True, 'all path candidates are inside workspace scope')
|
||||
|
||||
def validate_path(self, candidate: str | Path, cwd: str | Path | None = None) -> PathScopeDecision:
|
||||
raw = os.path.expandvars(os.path.expanduser(str(candidate)))
|
||||
if _is_windows_absolute(raw):
|
||||
return self._validate_windows_path(raw)
|
||||
base = Path(cwd).expanduser().resolve(strict=False) if cwd else self.roots[0]
|
||||
path = Path(raw)
|
||||
if not path.is_absolute():
|
||||
path = base / path
|
||||
expanded = self._expand_glob(path)
|
||||
for expanded_path in expanded:
|
||||
resolved = expanded_path.resolve(strict=False)
|
||||
if not any(_is_relative_to(resolved, root) for root in self.roots):
|
||||
return PathScopeDecision(
|
||||
False,
|
||||
'path resolves outside workspace scope',
|
||||
str(candidate),
|
||||
str(resolved),
|
||||
)
|
||||
return PathScopeDecision(True, 'path is inside workspace scope', str(candidate), str(expanded[0].resolve(strict=False)))
|
||||
|
||||
def _expand_glob(self, path: Path) -> tuple[Path, ...]:
|
||||
path_text = str(path)
|
||||
if any(char in path_text for char in _GLOB_META):
|
||||
matches = tuple(Path(match) for match in glob.glob(path_text, recursive=True))
|
||||
if matches:
|
||||
return matches
|
||||
# For unmatched globs, validate the stable non-glob parent prefix.
|
||||
stable_parts: list[str] = []
|
||||
for part in path.parts:
|
||||
if any(char in part for char in _GLOB_META):
|
||||
break
|
||||
stable_parts.append(part)
|
||||
if stable_parts:
|
||||
return (Path(*stable_parts),)
|
||||
return (path,)
|
||||
|
||||
def _validate_windows_path(self, raw: str) -> PathScopeDecision:
|
||||
candidate = PureWindowsPath(raw)
|
||||
for root in self.roots:
|
||||
root_text = str(root)
|
||||
if not _is_windows_absolute(root_text):
|
||||
continue
|
||||
try:
|
||||
candidate.relative_to(PureWindowsPath(root_text))
|
||||
return PathScopeDecision(True, 'windows path is inside workspace scope', raw, str(candidate))
|
||||
except ValueError:
|
||||
continue
|
||||
return PathScopeDecision(False, 'windows absolute path is outside workspace scope', raw, str(candidate))
|
||||
|
||||
|
||||
def extract_path_candidates(payload: str) -> tuple[str, ...]:
|
||||
"""Return conservative path-like operands from a shell/tool payload."""
|
||||
|
||||
try:
|
||||
tokens = shlex.split(payload, posix=True)
|
||||
except ValueError:
|
||||
tokens = payload.split()
|
||||
raw_tokens = payload.split()
|
||||
candidates: list[str] = []
|
||||
for token in (*tokens, *raw_tokens):
|
||||
if not token or token.startswith('-') or _ENV_ASSIGNMENT_RE.match(token):
|
||||
continue
|
||||
expanded = os.path.expandvars(os.path.expanduser(token))
|
||||
if _looks_like_path(token) or _looks_like_path(expanded):
|
||||
candidate = expanded if _looks_like_path(expanded) else token
|
||||
if candidate not in candidates:
|
||||
candidates.append(candidate)
|
||||
return tuple(candidates)
|
||||
|
||||
|
||||
def _looks_like_path(token: str) -> bool:
|
||||
return (
|
||||
token in {'.', '..'}
|
||||
or token.startswith(('./', '../', '/', '~/', '~/'))
|
||||
or '..' in token.split('/')
|
||||
or '/' in token
|
||||
or '\\' in token
|
||||
or any(char in token for char in _GLOB_META)
|
||||
or _is_windows_absolute(token)
|
||||
)
|
||||
|
||||
|
||||
def _is_windows_absolute(value: str) -> bool:
|
||||
return bool(_WINDOWS_DRIVE_RE.match(value) or _WINDOWS_UNC_RE.match(value))
|
||||
|
||||
|
||||
def _is_relative_to(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
@@ -1,20 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .path_scope import PathScopeDecision, WorkspacePathScope
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolPermissionContext:
|
||||
deny_names: frozenset[str] = field(default_factory=frozenset)
|
||||
deny_prefixes: tuple[str, ...] = ()
|
||||
workspace_scope: WorkspacePathScope | None = None
|
||||
cwd: Path | None = None
|
||||
|
||||
@classmethod
|
||||
def from_iterables(cls, deny_names: list[str] | None = None, deny_prefixes: list[str] | None = None) -> 'ToolPermissionContext':
|
||||
def from_iterables(
|
||||
cls,
|
||||
deny_names: list[str] | None = None,
|
||||
deny_prefixes: list[str] | None = None,
|
||||
workspace_root: str | Path | None = None,
|
||||
workspace_roots: list[str | Path] | tuple[str | Path, ...] | None = None,
|
||||
cwd: str | Path | None = None,
|
||||
) -> 'ToolPermissionContext':
|
||||
roots: list[str | Path] = []
|
||||
if workspace_roots:
|
||||
roots.extend(workspace_roots)
|
||||
if workspace_root is not None:
|
||||
roots.append(workspace_root)
|
||||
return cls(
|
||||
deny_names=frozenset(name.lower() for name in (deny_names or [])),
|
||||
deny_prefixes=tuple(prefix.lower() for prefix in (deny_prefixes or [])),
|
||||
workspace_scope=WorkspacePathScope.from_roots(roots) if roots else None,
|
||||
cwd=Path(cwd).expanduser().resolve(strict=False) if cwd is not None else None,
|
||||
)
|
||||
|
||||
def blocks(self, tool_name: str) -> bool:
|
||||
lowered = tool_name.lower()
|
||||
return lowered in self.deny_names or any(lowered.startswith(prefix) for prefix in self.deny_prefixes)
|
||||
|
||||
def validate_payload_scope(self, tool_name: str, payload: str) -> PathScopeDecision:
|
||||
if self.workspace_scope is None or not _scope_checked_tool(tool_name):
|
||||
return PathScopeDecision(True, 'workspace path scope not required for this tool')
|
||||
return self.workspace_scope.validate_payload(payload, cwd=self.cwd)
|
||||
|
||||
|
||||
def _scope_checked_tool(tool_name: str) -> bool:
|
||||
lowered = tool_name.lower()
|
||||
return any(marker in lowered for marker in ('bash', 'shell', 'powershell', 'fileread', 'filewrite', 'fileedit'))
|
||||
|
||||
@@ -82,6 +82,7 @@ class QueryEnginePort:
|
||||
f'Matched commands: {", ".join(matched_commands) if matched_commands else "none"}',
|
||||
f'Matched tools: {", ".join(matched_tools) if matched_tools else "none"}',
|
||||
f'Permission denials: {len(denied_tools)}',
|
||||
*(f'Permission denial: {denial.tool_name} status={denial.status} reason={denial.reason}' for denial in denied_tools),
|
||||
]
|
||||
output = self._format_output(summary_lines)
|
||||
projected_usage = self.total_usage.add_turn(prompt, output)
|
||||
@@ -116,7 +117,13 @@ class QueryEnginePort:
|
||||
if matched_tools:
|
||||
yield {'type': 'tool_match', 'tools': matched_tools}
|
||||
if denied_tools:
|
||||
yield {'type': 'permission_denial', 'denials': [denial.tool_name for denial in denied_tools]}
|
||||
yield {
|
||||
'type': 'permission_denial',
|
||||
'denials': [
|
||||
{'tool_name': denial.tool_name, 'reason': denial.reason, 'status': denial.status}
|
||||
for denial in denied_tools
|
||||
],
|
||||
}
|
||||
result = self.submit_message(prompt, matched_commands, matched_tools, denied_tools)
|
||||
yield {'type': 'message_delta', 'text': result.output}
|
||||
yield {
|
||||
|
||||
17
src/tools.py
17
src/tools.py
@@ -78,10 +78,25 @@ def find_tools(query: str, limit: int = 20) -> list[PortingModule]:
|
||||
return matches[:limit]
|
||||
|
||||
|
||||
def execute_tool(name: str, payload: str = '') -> ToolExecution:
|
||||
def execute_tool(name: str, payload: str = '', permission_context: ToolPermissionContext | None = None) -> ToolExecution:
|
||||
module = get_tool(name)
|
||||
if module is None:
|
||||
return ToolExecution(name=name, source_hint='', payload=payload, handled=False, message=f'Unknown mirrored tool: {name}')
|
||||
if permission_context and permission_context.blocks(module.name):
|
||||
return ToolExecution(name=module.name, source_hint=module.source_hint, payload=payload, handled=False, message=f"Permission denied for mirrored tool '{module.name}'.")
|
||||
if permission_context:
|
||||
scope_decision = permission_context.validate_payload_scope(module.name, payload)
|
||||
if not scope_decision.allowed:
|
||||
return ToolExecution(
|
||||
name=module.name,
|
||||
source_hint=module.source_hint,
|
||||
payload=payload,
|
||||
handled=False,
|
||||
message=(
|
||||
f"Permission denied for mirrored tool '{module.name}': {scope_decision.reason}"
|
||||
f" (candidate={scope_decision.candidate!r}, resolved={scope_decision.resolved!r})."
|
||||
),
|
||||
)
|
||||
action = f"Mirrored tool '{module.name}' from {module.source_hint} would handle payload {payload!r}."
|
||||
return ToolExecution(name=module.name, source_hint=module.source_hint, payload=payload, handled=True, message=action)
|
||||
|
||||
|
||||
135
tests/test_security_scope.py
Normal file
135
tests/test_security_scope.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.models import PermissionDenial
|
||||
from src.path_scope import WorkspacePathScope, extract_path_candidates
|
||||
from src.permissions import ToolPermissionContext
|
||||
from src.query_engine import QueryEnginePort
|
||||
from src.tools import execute_tool
|
||||
|
||||
|
||||
class WorkspacePathScopeTests(unittest.TestCase):
|
||||
def test_direct_parent_escape_is_denied(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
workspace = Path(tmp) / 'workspace'
|
||||
workspace.mkdir()
|
||||
decision = WorkspacePathScope.from_root(workspace).validate_payload('cat ../secret.txt')
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertIn('outside workspace scope', decision.reason)
|
||||
|
||||
def test_issue_3007_symlink_escape_is_denied(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
workspace = root / 'workspace'
|
||||
outside = root / 'outside'
|
||||
workspace.mkdir()
|
||||
outside.mkdir()
|
||||
(outside / 'secret.txt').write_text('secret')
|
||||
link = workspace / 'linked-outside'
|
||||
link.symlink_to(outside, target_is_directory=True)
|
||||
|
||||
decision = WorkspacePathScope.from_root(workspace).validate_payload('cat linked-outside/secret.txt')
|
||||
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertIn(str(outside.resolve()), decision.resolved or '')
|
||||
|
||||
def test_glob_expansion_must_stay_inside_workspace(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
workspace = root / 'workspace'
|
||||
outside = root / 'outside'
|
||||
workspace.mkdir()
|
||||
outside.mkdir()
|
||||
(outside / 'secret.txt').write_text('secret')
|
||||
|
||||
decision = WorkspacePathScope.from_root(workspace).validate_payload(f'cat {outside}/*.txt')
|
||||
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertEqual(str((outside / 'secret.txt').resolve()), decision.resolved)
|
||||
|
||||
def test_shell_environment_expansion_is_validated(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
workspace = root / 'workspace'
|
||||
outside = root / 'outside'
|
||||
workspace.mkdir()
|
||||
outside.mkdir()
|
||||
previous = os.environ.get('CLAW_SCOPE_OUTSIDE')
|
||||
os.environ['CLAW_SCOPE_OUTSIDE'] = str(outside)
|
||||
try:
|
||||
self.assertEqual((f'{outside}/secret.txt',), extract_path_candidates('cat $CLAW_SCOPE_OUTSIDE/secret.txt'))
|
||||
decision = WorkspacePathScope.from_root(workspace).validate_payload('cat $CLAW_SCOPE_OUTSIDE/secret.txt')
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop('CLAW_SCOPE_OUTSIDE', None)
|
||||
else:
|
||||
os.environ['CLAW_SCOPE_OUTSIDE'] = previous
|
||||
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertIn(str(outside.resolve()), decision.resolved or '')
|
||||
|
||||
def test_explicit_worktree_roots_are_allowed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
workspace = root / 'workspace'
|
||||
worktree = root / 'worktree'
|
||||
workspace.mkdir()
|
||||
worktree.mkdir()
|
||||
(worktree / 'file.txt').write_text('ok')
|
||||
|
||||
decision = WorkspacePathScope.from_roots((workspace, worktree)).validate_payload(f'cat {worktree}/file.txt')
|
||||
|
||||
self.assertTrue(decision.allowed, decision.reason)
|
||||
|
||||
def test_windows_absolute_paths_are_denied_for_posix_workspace(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
workspace = Path(tmp) / 'workspace'
|
||||
workspace.mkdir()
|
||||
|
||||
drive_decision = WorkspacePathScope.from_root(workspace).validate_payload(r'type C:\Users\other\secret.txt')
|
||||
unc_decision = WorkspacePathScope.from_root(workspace).validate_payload(r'type \\server\share\secret.txt')
|
||||
|
||||
self.assertFalse(drive_decision.allowed)
|
||||
self.assertIn('windows absolute path', drive_decision.reason)
|
||||
self.assertFalse(unc_decision.allowed)
|
||||
self.assertIn('windows absolute path', unc_decision.reason)
|
||||
|
||||
def test_file_and_shell_tools_use_workspace_scope_context(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
workspace = root / 'workspace'
|
||||
outside = root / 'outside'
|
||||
workspace.mkdir()
|
||||
outside.mkdir()
|
||||
context = ToolPermissionContext.from_iterables(workspace_root=workspace, cwd=workspace)
|
||||
|
||||
file_result = execute_tool('FileReadTool', f'{outside}/secret.txt', permission_context=context)
|
||||
shell_result = execute_tool('BashTool', f'cat {outside}/secret.txt', permission_context=context)
|
||||
inside_result = execute_tool('FileReadTool', './allowed.txt', permission_context=context)
|
||||
|
||||
self.assertFalse(file_result.handled)
|
||||
self.assertIn('Permission denied', file_result.message)
|
||||
self.assertFalse(shell_result.handled)
|
||||
self.assertIn('Permission denied', shell_result.message)
|
||||
self.assertTrue(inside_result.handled)
|
||||
|
||||
def test_permission_denial_stream_events_expose_status_and_reason(self) -> None:
|
||||
engine = QueryEnginePort.from_workspace()
|
||||
denial = PermissionDenial('BashTool', 'path resolves outside workspace scope')
|
||||
|
||||
events = list(engine.stream_submit_message('cat ../secret.txt', matched_tools=('BashTool',), denied_tools=(denial,)))
|
||||
permission_event = next(event for event in events if event['type'] == 'permission_denial')
|
||||
result = engine.submit_message('cat ../secret.txt', matched_tools=('BashTool',), denied_tools=(denial,))
|
||||
|
||||
self.assertEqual('blocked', permission_event['denials'][0]['status'])
|
||||
self.assertEqual('path resolves outside workspace scope', permission_event['denials'][0]['reason'])
|
||||
self.assertIn('status=blocked', result.output)
|
||||
self.assertIn('path resolves outside workspace scope', result.output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user