Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34fe84fdd9 | ||
|
|
a4e70af157 | ||
|
|
15e9aa6b34 | ||
|
|
7d3de2bb44 | ||
|
|
fbaf5bf959 | ||
|
|
9c61dfa595 | ||
|
|
90cb005019 | ||
|
|
12580ae9ce |
@@ -35,3 +35,6 @@ double_timmy_log.md
|
|||||||
pipeline.db
|
pipeline.db
|
||||||
pipeline.db.bak*
|
pipeline.db.bak*
|
||||||
session_store.json
|
session_store.json
|
||||||
|
|
||||||
|
# Full untruncated merge attempt reports (one file per attempt, pruned automatically)
|
||||||
|
merge_reports/
|
||||||
|
|||||||
@@ -1846,6 +1846,7 @@
|
|||||||
"mod merge_tools",
|
"mod merge_tools",
|
||||||
"mod progress",
|
"mod progress",
|
||||||
"mod qa_tools",
|
"mod qa_tools",
|
||||||
|
"mod session",
|
||||||
"mod shell_tools",
|
"mod shell_tools",
|
||||||
"mod status_tools",
|
"mod status_tools",
|
||||||
"mod story_tools",
|
"mod story_tools",
|
||||||
@@ -1867,6 +1868,11 @@
|
|||||||
"fn tool_reject_qa",
|
"fn tool_reject_qa",
|
||||||
"fn tool_launch_qa_app"
|
"fn tool_launch_qa_app"
|
||||||
],
|
],
|
||||||
|
"server/src/http/mcp/session.rs": [
|
||||||
|
"const DEFAULT_SESSION",
|
||||||
|
"static SESSION_ID",
|
||||||
|
"fn current"
|
||||||
|
],
|
||||||
"server/src/http/mcp/shell_tools/exec.rs": [
|
"server/src/http/mcp/shell_tools/exec.rs": [
|
||||||
"fn validate_working_dir",
|
"fn validate_working_dir",
|
||||||
"fn tool_run_command"
|
"fn tool_run_command"
|
||||||
@@ -2129,6 +2135,7 @@
|
|||||||
"fn append_root_gitignore_entries"
|
"fn append_root_gitignore_entries"
|
||||||
],
|
],
|
||||||
"server/src/io/fs/scaffold/mod.rs": [
|
"server/src/io/fs/scaffold/mod.rs": [
|
||||||
|
"fn ensure_gitignore_entries",
|
||||||
"fn scaffold_story_kit"
|
"fn scaffold_story_kit"
|
||||||
],
|
],
|
||||||
"server/src/io/fs/scaffold/templates.rs": [
|
"server/src/io/fs/scaffold/templates.rs": [
|
||||||
@@ -2576,17 +2583,12 @@
|
|||||||
"server/src/service/common/mod.rs": [
|
"server/src/service/common/mod.rs": [
|
||||||
"mod item_id"
|
"mod item_id"
|
||||||
],
|
],
|
||||||
"server/src/service/diagnostics/io.rs": [
|
|
||||||
"fn add_permission_rule"
|
|
||||||
],
|
|
||||||
"server/src/service/diagnostics/mod.rs": [
|
"server/src/service/diagnostics/mod.rs": [
|
||||||
"mod io",
|
|
||||||
"mod permission",
|
"mod permission",
|
||||||
"enum Error"
|
"enum Error"
|
||||||
],
|
],
|
||||||
"server/src/service/diagnostics/permission.rs": [
|
"server/src/service/diagnostics/permission.rs": [
|
||||||
"fn generate_permission_rule",
|
"fn generate_permission_rule"
|
||||||
"fn is_dominated_by_wildcard"
|
|
||||||
],
|
],
|
||||||
"server/src/service/disk_watch/io.rs": [
|
"server/src/service/disk_watch/io.rs": [
|
||||||
"fn free_space_bytes",
|
"fn free_space_bytes",
|
||||||
@@ -2976,7 +2978,11 @@
|
|||||||
"fn new",
|
"fn new",
|
||||||
"fn insert",
|
"fn insert",
|
||||||
"fn resolve_oldest",
|
"fn resolve_oldest",
|
||||||
"fn remove_by_request_id"
|
"fn remove_by_request_id",
|
||||||
|
"struct RememberedPermissions",
|
||||||
|
"fn new",
|
||||||
|
"fn remember",
|
||||||
|
"fn is_remembered"
|
||||||
],
|
],
|
||||||
"server/src/service/pipeline/mod.rs": [
|
"server/src/service/pipeline/mod.rs": [
|
||||||
"fn aggregate_pipeline_counts"
|
"fn aggregate_pipeline_counts"
|
||||||
|
|||||||
Generated
+21
-21
@@ -456,9 +456,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.67"
|
version = "1.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
|
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
@@ -1467,9 +1467,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -1482,9 +1482,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
@@ -1492,15 +1492,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-executor"
|
name = "futures-executor"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
@@ -1520,15 +1520,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-io"
|
name = "futures-io"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-macro"
|
name = "futures-macro"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -1537,21 +1537,21 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -1925,7 +1925,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "huskies"
|
name = "huskies"
|
||||||
version = "0.14.0"
|
version = "0.14.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "huskies",
|
"name": "huskies",
|
||||||
"version": "0.14.0",
|
"version": "0.14.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "huskies",
|
"name": "huskies",
|
||||||
"version": "0.14.0",
|
"version": "0.14.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "huskies",
|
"name": "huskies",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.14.0",
|
"version": "0.14.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function PermissionDialog({
|
|||||||
fontSize: "0.9em",
|
fontSize: "0.9em",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Always Allow
|
Don't ask again this session
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -453,6 +453,67 @@ describe("StagePanel", () => {
|
|||||||
expect(badge).toHaveTextContent("BLOCKED");
|
expect(badge).toHaveTextContent("BLOCKED");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Story 1215 — confirms the board shows an active-agent indicator instead
|
||||||
|
// of the plain blocked icon whenever a running/pending agent is present,
|
||||||
|
// even though blocked=true, and falls back to the plain icon otherwise.
|
||||||
|
it("shows active-agent indicator instead of blocked icon when a running/pending agent is present, even if blocked=true", () => {
|
||||||
|
const items: PipelineStageItem[] = [
|
||||||
|
{
|
||||||
|
story_id: "54_story_blocked_active_agent",
|
||||||
|
name: "Blocked With Active Agent",
|
||||||
|
error: null,
|
||||||
|
merge_failure: null,
|
||||||
|
agent: { agent_name: "coder", model: "claude", status: "running" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
|
depends_on: null,
|
||||||
|
blocked: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
render(<StagePanel title="Current" items={items} />);
|
||||||
|
const badge = screen.getByTestId("blocked-badge-54_story_blocked_active_agent");
|
||||||
|
expect(badge).not.toHaveTextContent("BLOCKED");
|
||||||
|
expect(badge).toHaveTextContent("RECOVERING");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drives the distinct indicator off the agent's running/pending status, not the blocked flag alone", () => {
|
||||||
|
const items: PipelineStageItem[] = [
|
||||||
|
{
|
||||||
|
story_id: "55_story_blocked_stale_agent",
|
||||||
|
name: "Blocked With Completed Agent",
|
||||||
|
error: null,
|
||||||
|
merge_failure: null,
|
||||||
|
agent: { agent_name: "coder", model: "claude", status: "completed" },
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
|
depends_on: null,
|
||||||
|
blocked: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
render(<StagePanel title="Current" items={items} />);
|
||||||
|
const badge = screen.getByTestId("blocked-badge-55_story_blocked_stale_agent");
|
||||||
|
expect(badge).toHaveTextContent("BLOCKED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the plain blocked icon for a story that is blocked with no live agent", () => {
|
||||||
|
const items: PipelineStageItem[] = [
|
||||||
|
{
|
||||||
|
story_id: "56_story_blocked_no_agent",
|
||||||
|
name: "Blocked No Agent",
|
||||||
|
error: null,
|
||||||
|
merge_failure: null,
|
||||||
|
agent: null,
|
||||||
|
review_hold: null,
|
||||||
|
qa: null,
|
||||||
|
depends_on: null,
|
||||||
|
blocked: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
render(<StagePanel title="Current" items={items} />);
|
||||||
|
const badge = screen.getByTestId("blocked-badge-56_story_blocked_no_agent");
|
||||||
|
expect(badge).toHaveTextContent("BLOCKED");
|
||||||
|
});
|
||||||
|
|
||||||
it("shows spinning icon for merge_failure item with running mergemaster", () => {
|
it("shows spinning icon for merge_failure item with running mergemaster", () => {
|
||||||
const items: PipelineStageItem[] = [
|
const items: PipelineStageItem[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "huskies"
|
name = "huskies"
|
||||||
version = "0.14.0"
|
version = "0.14.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ pub(super) fn build_agent_app_context(
|
|||||||
permission_registry,
|
permission_registry,
|
||||||
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
|
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
|
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||||
status: agents.status_broadcaster(),
|
status: agents.status_broadcaster(),
|
||||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -317,6 +317,7 @@ mod tests {
|
|||||||
permission_registry: ResponderRegistry::new(),
|
permission_registry: ResponderRegistry::new(),
|
||||||
pending_perm_replies: PendingPermReplies::new(),
|
pending_perm_replies: PendingPermReplies::new(),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
|
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||||
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use super::format_user_prompt;
|
|||||||
|
|
||||||
/// Text posted to the room by [`spawn_digging_in_watcher`] when a turn runs
|
/// Text posted to the room by [`spawn_digging_in_watcher`] when a turn runs
|
||||||
/// long without emitting any user-facing text.
|
/// long without emitting any user-facing text.
|
||||||
const DIGGING_IN_MESSAGE: &str = "Still digging in — this turn is taking a bit longer than usual.";
|
const DIGGING_IN_MESSAGE: &str = "Working...";
|
||||||
|
|
||||||
/// Spawns a background watcher that posts a single "digging in" notice to
|
/// Spawns a background watcher that posts a single "digging in" notice to
|
||||||
/// `room_id` if `threshold` elapses before `sent_any_text` becomes `true`.
|
/// `room_id` if `threshold` elapses before `sent_any_text` becomes `true`.
|
||||||
@@ -392,7 +392,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// AC 1: a turn that runs longer than the threshold without sending any
|
/// AC 1: a turn that runs longer than the threshold without sending any
|
||||||
/// text gets exactly one "digging in" notice.
|
/// text gets exactly one "Working..." notice.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn digging_in_fires_after_threshold_when_no_text_sent() {
|
async fn digging_in_fires_after_threshold_when_no_text_sent() {
|
||||||
let transport = Arc::new(CapturingTransport::new());
|
let transport = Arc::new(CapturingTransport::new());
|
||||||
@@ -405,14 +405,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
handle.await.unwrap();
|
handle.await.unwrap();
|
||||||
assert_eq!(transport.sent_count(), 1);
|
assert_eq!(transport.sent_count(), 1);
|
||||||
assert!(
|
assert_eq!(transport.last_message().unwrap(), "Working...");
|
||||||
transport
|
|
||||||
.last_message()
|
|
||||||
.unwrap()
|
|
||||||
.to_lowercase()
|
|
||||||
.contains("digging in"),
|
|
||||||
"notice should mention 'digging in'"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AC 2: if text is sent before the threshold elapses, the watcher must
|
/// AC 2: if text is sent before the threshold elapses, the watcher must
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ mod tests {
|
|||||||
permission_registry: crate::service::permission_router::ResponderRegistry::new(),
|
permission_registry: crate::service::permission_router::ResponderRegistry::new(),
|
||||||
pending_perm_replies: PendingPermReplies::new(),
|
pending_perm_replies: PendingPermReplies::new(),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
|
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||||
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -624,6 +624,7 @@ mod tests {
|
|||||||
permission_registry: registry,
|
permission_registry: registry,
|
||||||
pending_perm_replies: PendingPermReplies::new(),
|
pending_perm_replies: PendingPermReplies::new(),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
|
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||||
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||||
});
|
});
|
||||||
@@ -655,6 +656,7 @@ mod tests {
|
|||||||
permission_registry: ResponderRegistry::new(),
|
permission_registry: ResponderRegistry::new(),
|
||||||
pending_perm_replies: PendingPermReplies::new(),
|
pending_perm_replies: PendingPermReplies::new(),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
|
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||||
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1440,12 +1440,18 @@ fn interpret_docker_run_error(stderr: &str, image: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Host ports reserved for the sled SSH range and never handed out as a
|
||||||
|
/// newly allocated `ssh_port`, even when momentarily bindable during a scan.
|
||||||
|
const RESERVED_SSH_PORTS: std::ops::RangeInclusive<u16> = 2200..=2202;
|
||||||
|
|
||||||
/// Scan `start..start+range` for a bindable TCP port on 127.0.0.1.
|
/// Scan `start..start+range` for a bindable TCP port on 127.0.0.1.
|
||||||
///
|
///
|
||||||
/// Returns `Some(port)` for the first port that can be bound, or `None` if all
|
/// Returns `Some(port)` for the first port that can be bound, or `None` if all
|
||||||
/// ports in the range are occupied.
|
/// ports in the range are occupied. Ports in [`RESERVED_SSH_PORTS`] are never
|
||||||
|
/// returned, even if bindable at scan time.
|
||||||
fn find_free_port_in_range(start: u16, range: u16) -> Option<u16> {
|
fn find_free_port_in_range(start: u16, range: u16) -> Option<u16> {
|
||||||
(start..start.saturating_add(range))
|
(start..start.saturating_add(range))
|
||||||
|
.filter(|port| !RESERVED_SSH_PORTS.contains(port))
|
||||||
.find(|&port| std::net::TcpListener::bind(("127.0.0.1", port)).is_ok())
|
.find(|&port| std::net::TcpListener::bind(("127.0.0.1", port)).is_ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1720,6 +1726,21 @@ mod tests {
|
|||||||
assert_eq!(find_free_port_in_range(port, 1), None);
|
assert_eq!(find_free_port_in_range(port, 1), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_free_port_skips_reserved_ssh_range() {
|
||||||
|
// The reserved 2200-2202 sled SSH range must never be handed out as
|
||||||
|
// a newly allocated ssh_port, even when those ports are bindable at
|
||||||
|
// scan time. Loop many times to rule out a flaky, timing-dependent
|
||||||
|
// pass rather than trusting a single lucky draw.
|
||||||
|
for _ in 0..100 {
|
||||||
|
let port = find_free_port(2200).expect("expected Some(port) in scan range");
|
||||||
|
assert!(
|
||||||
|
!RESERVED_SSH_PORTS.contains(&port),
|
||||||
|
"returned port {port} falls within the reserved 2200-2202 SSH range"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detect_stack_go_mod() {
|
fn detect_stack_go_mod() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ mod tests {
|
|||||||
permission_registry: crate::service::permission_router::ResponderRegistry::new(),
|
permission_registry: crate::service::permission_router::ResponderRegistry::new(),
|
||||||
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
|
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
|
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||||
});
|
});
|
||||||
Arc::new(WhatsAppWebhookContext {
|
Arc::new(WhatsAppWebhookContext {
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ pub enum PermissionDecision {
|
|||||||
Deny,
|
Deny,
|
||||||
/// One-time approval.
|
/// One-time approval.
|
||||||
Approve,
|
Approve,
|
||||||
/// Approve and persist the rule to `.claude/settings.json` so Claude Code's
|
/// Approve, and remember `(tool, target-pattern)` for the rest of the
|
||||||
/// built-in permission system handles future checks without prompting.
|
/// requesting agent's session (story 1218) — subsequent matching
|
||||||
|
/// requests auto-approve without forwarding to chat. Scoped in-memory to
|
||||||
|
/// the session that made the request (see
|
||||||
|
/// `service::permission_router::RememberedPermissions`); never persisted
|
||||||
|
/// to disk and never shared with another agent or story.
|
||||||
AlwaysAllow,
|
AlwaysAllow,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +129,7 @@ impl AppContext {
|
|||||||
permission_registry,
|
permission_registry,
|
||||||
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
|
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
|
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||||
status: agents.status_broadcaster(),
|
status: agents.status_broadcaster(),
|
||||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
use crate::service::diagnostics::{add_permission_rule, generate_permission_rule};
|
use crate::http::mcp::session;
|
||||||
|
use crate::service::diagnostics::generate_permission_rule;
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use crate::slog_warn;
|
use crate::slog_warn;
|
||||||
|
|
||||||
@@ -29,6 +30,23 @@ pub(crate) async fn tool_prompt_permission(
|
|||||||
return Ok(json!({"behavior": "allow", "updatedInput": tool_input}).to_string());
|
return Ok(json!({"behavior": "allow", "updatedInput": tool_input}).to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let session_id = session::current();
|
||||||
|
let rule = generate_permission_rule(&tool_name, &tool_input);
|
||||||
|
|
||||||
|
// Auto-approve without forwarding when this exact (tool, target-pattern)
|
||||||
|
// was already remembered for this agent's session (story 1218). Logged
|
||||||
|
// here for auditability since it bypasses the chat approval dialog.
|
||||||
|
if ctx
|
||||||
|
.services
|
||||||
|
.remembered_permissions
|
||||||
|
.is_remembered(&session_id, &rule)
|
||||||
|
{
|
||||||
|
crate::slog!(
|
||||||
|
"[permission] Auto-approved '{tool_name}' (remembered rule '{rule}' for session '{session_id}')"
|
||||||
|
);
|
||||||
|
return Ok(json!({"behavior": "allow", "updatedInput": tool_input}).to_string());
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-deny immediately if no responder is currently registered to
|
// Auto-deny immediately if no responder is currently registered to
|
||||||
// receive forwarded permission requests. The Matrix bot's
|
// receive forwarded permission requests. The Matrix bot's
|
||||||
// permission_listener task, sled uplinks, and per-message chat transports
|
// permission_listener task, sled uplinks, and per-message chat transports
|
||||||
@@ -83,15 +101,14 @@ pub(crate) async fn tool_prompt_permission(
|
|||||||
.map_err(|_| "Permission response channel closed unexpectedly".to_string())?;
|
.map_err(|_| "Permission response channel closed unexpectedly".to_string())?;
|
||||||
|
|
||||||
if decision == PermissionDecision::AlwaysAllow {
|
if decision == PermissionDecision::AlwaysAllow {
|
||||||
// Persist the rule so Claude Code won't prompt again for this tool.
|
// Remember for the rest of this agent's session (story 1218) — never
|
||||||
if let Some(root) = ctx.state.project_root.lock().unwrap().clone() {
|
// written to disk, never visible to another session's requests.
|
||||||
let rule = generate_permission_rule(&tool_name, &tool_input);
|
ctx.services
|
||||||
if let Err(e) = add_permission_rule(&root, &rule) {
|
.remembered_permissions
|
||||||
slog_warn!("[permission] Failed to write always-allow rule: {e}");
|
.remember(&session_id, &rule);
|
||||||
} else {
|
slog!(
|
||||||
slog!("[permission] Added always-allow rule: {rule}");
|
"[permission] Remembered rule '{rule}' for session '{session_id}' — future matches auto-approve without prompting"
|
||||||
}
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if decision == PermissionDecision::Approve || decision == PermissionDecision::AlwaysAllow {
|
if decision == PermissionDecision::Approve || decision == PermissionDecision::AlwaysAllow {
|
||||||
@@ -238,91 +255,86 @@ mod tests {
|
|||||||
assert_eq!(rule, "mcp__huskies__create_story");
|
assert_eq!(rule, "mcp__huskies__create_story");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Settings.json writing tests ──────────────────────────────
|
// ── Remembered ("don't ask again this session") tests (story 1218) ──
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn add_rule_creates_settings_file_when_missing() {
|
async fn remembered_rule_auto_approves_without_forwarding() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
add_permission_rule(tmp.path(), "Edit").unwrap();
|
let ctx = test_ctx(tmp.path());
|
||||||
|
|
||||||
let content = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
|
// Pre-remember the rule for the default session (no X-Huskies-Session
|
||||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
// header scope installed in this test) — no responder is registered,
|
||||||
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
// so if the request were forwarded it would auto-deny instead.
|
||||||
assert!(allow.contains(&json!("Edit")));
|
ctx.services
|
||||||
}
|
.remembered_permissions
|
||||||
|
.remember(&session::current(), "Bash(git *)");
|
||||||
|
|
||||||
#[test]
|
let result = tool_prompt_permission(
|
||||||
fn add_rule_does_not_duplicate_existing() {
|
&json!({"tool_name": "Bash", "input": {"command": "git status"}}),
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
&ctx,
|
||||||
add_permission_rule(tmp.path(), "Edit").unwrap();
|
|
||||||
add_permission_rule(tmp.path(), "Edit").unwrap();
|
|
||||||
|
|
||||||
let content = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
|
|
||||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
|
||||||
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
|
||||||
let count = allow.iter().filter(|v| v == &&json!("Edit")).count();
|
|
||||||
assert_eq!(count, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn add_rule_skips_when_wildcard_already_covers() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let claude_dir = tmp.path().join(".claude");
|
|
||||||
fs::create_dir_all(&claude_dir).unwrap();
|
|
||||||
fs::write(
|
|
||||||
claude_dir.join("settings.json"),
|
|
||||||
r#"{"permissions":{"allow":["mcp__huskies__*"]}}"#,
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.await
|
||||||
|
.expect("remembered rule must short-circuit before the no-responder auto-deny");
|
||||||
|
|
||||||
add_permission_rule(tmp.path(), "mcp__huskies__create_story").unwrap();
|
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||||
|
assert_eq!(parsed["behavior"], "allow");
|
||||||
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
|
|
||||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
|
||||||
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
|
||||||
assert_eq!(allow.len(), 1);
|
|
||||||
assert_eq!(allow[0], "mcp__huskies__*");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn add_rule_appends_to_existing_rules() {
|
async fn always_allow_decision_remembers_rule_for_session_not_disk() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let claude_dir = tmp.path().join(".claude");
|
let ctx = test_ctx(tmp.path());
|
||||||
fs::create_dir_all(&claude_dir).unwrap();
|
|
||||||
fs::write(
|
let (guard, mut rx) = ctx.services.permission_registry.register();
|
||||||
claude_dir.join("settings.json"),
|
tokio::spawn(async move {
|
||||||
r#"{"permissions":{"allow":["Edit"]}}"#,
|
if let Some(forward) = rx.recv().await {
|
||||||
|
let _ = forward
|
||||||
|
.response_tx
|
||||||
|
.send(crate::http::context::PermissionDecision::AlwaysAllow);
|
||||||
|
}
|
||||||
|
drop(guard);
|
||||||
|
});
|
||||||
|
|
||||||
|
tool_prompt_permission(
|
||||||
|
&json!({"tool_name": "Bash", "input": {"command": "git status"}}),
|
||||||
|
&ctx,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.await
|
||||||
|
.expect("always-allow must succeed");
|
||||||
|
|
||||||
add_permission_rule(tmp.path(), "Write").unwrap();
|
assert!(
|
||||||
|
ctx.services
|
||||||
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
|
.remembered_permissions
|
||||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
.is_remembered(&session::current(), "Bash(git *)"),
|
||||||
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
"AlwaysAllow must remember the rule in-memory for this session"
|
||||||
assert_eq!(allow.len(), 2);
|
);
|
||||||
assert!(allow.contains(&json!("Edit")));
|
assert!(
|
||||||
assert!(allow.contains(&json!("Write")));
|
!tmp.path().join(".claude/settings.json").exists(),
|
||||||
|
"AlwaysAllow must not write to disk (story 1218: session-scoped only)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn add_rule_preserves_other_settings_fields() {
|
async fn remembered_rule_does_not_cross_sessions() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let claude_dir = tmp.path().join(".claude");
|
let ctx = test_ctx(tmp.path());
|
||||||
fs::create_dir_all(&claude_dir).unwrap();
|
ctx.services
|
||||||
fs::write(
|
.remembered_permissions
|
||||||
claude_dir.join("settings.json"),
|
.remember("story-a", "Bash(git *)");
|
||||||
r#"{"permissions":{"allow":["Edit"]},"enabledMcpjsonServers":["huskies"]}"#,
|
|
||||||
|
// Current (default) session never remembered this rule, and no
|
||||||
|
// responder is registered, so it must fall through to auto-deny.
|
||||||
|
let result = tool_prompt_permission(
|
||||||
|
&json!({"tool_name": "Bash", "input": {"command": "git status"}}),
|
||||||
|
&ctx,
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||||
add_permission_rule(tmp.path(), "Write").unwrap();
|
assert_eq!(
|
||||||
|
parsed["behavior"], "deny",
|
||||||
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
|
"a rule remembered for a different session must not auto-approve this one"
|
||||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
);
|
||||||
let servers = settings["enabledMcpjsonServers"].as_array().unwrap();
|
|
||||||
assert_eq!(servers.len(), 1);
|
|
||||||
assert_eq!(servers[0], "huskies");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── move_story tool tests ─────────────────────────────────────
|
// ── move_story tool tests ─────────────────────────────────────
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ pub mod merge_tools;
|
|||||||
pub mod progress;
|
pub mod progress;
|
||||||
/// MCP tools for QA request, approve, and reject workflows.
|
/// MCP tools for QA request, approve, and reject workflows.
|
||||||
pub mod qa_tools;
|
pub mod qa_tools;
|
||||||
|
/// Task-local session identifier for the requesting MCP client, read from
|
||||||
|
/// the `X-Huskies-Session` header so `tool_prompt_permission` can scope
|
||||||
|
/// remembered approvals per requesting agent.
|
||||||
|
pub mod session;
|
||||||
/// MCP tools for running shell commands and test suites.
|
/// MCP tools for running shell commands and test suites.
|
||||||
pub mod shell_tools;
|
pub mod shell_tools;
|
||||||
/// MCP tools for pipeline status, story todos, and triage dump.
|
/// MCP tools for pipeline status, story todos, and triage dump.
|
||||||
@@ -71,6 +75,11 @@ pub async fn mcp_get_handler() -> Response {
|
|||||||
/// `tools/call`, and `notifications/*`.
|
/// `tools/call`, and `notifications/*`.
|
||||||
#[handler]
|
#[handler]
|
||||||
pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc<AppContext>>) -> Response {
|
pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc<AppContext>>) -> Response {
|
||||||
|
let session_id = req
|
||||||
|
.header("x-huskies-session")
|
||||||
|
.unwrap_or(session::DEFAULT_SESSION)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let content_type = req.header("content-type").unwrap_or("");
|
let content_type = req.header("content-type").unwrap_or("");
|
||||||
if !content_type.is_empty() && !content_type.contains("application/json") {
|
if !content_type.is_empty() && !content_type.contains("application/json") {
|
||||||
return json_response(JsonRpcResponse::error(
|
return json_response(JsonRpcResponse::error(
|
||||||
@@ -125,7 +134,7 @@ pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc<AppConte
|
|||||||
.and_then(|m| m.get("progressToken"))
|
.and_then(|m| m.get("progressToken"))
|
||||||
.cloned();
|
.cloned();
|
||||||
if let (true, Some(token)) = (accepts_sse, progress_token) {
|
if let (true, Some(token)) = (accepts_sse, progress_token) {
|
||||||
return sse_tools_call(rpc.id, rpc.params, token, Arc::clone(&ctx)).await;
|
return sse_tools_call(rpc.id, rpc.params, token, Arc::clone(&ctx), session_id).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +143,11 @@ pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc<AppConte
|
|||||||
"tools/list" => {
|
"tools/list" => {
|
||||||
JsonRpcResponse::success(rpc.id, json!({ "tools": tools_list::list_tools() }))
|
JsonRpcResponse::success(rpc.id, json!({ "tools": tools_list::list_tools() }))
|
||||||
}
|
}
|
||||||
"tools/call" => handle_tools_call(rpc.id, &rpc.params, &ctx).await,
|
"tools/call" => {
|
||||||
|
session::SESSION_ID
|
||||||
|
.scope(session_id, handle_tools_call(rpc.id, &rpc.params, &ctx))
|
||||||
|
.await
|
||||||
|
}
|
||||||
_ => JsonRpcResponse::error(rpc.id, -32601, format!("Unknown method: {}", rpc.method)),
|
_ => JsonRpcResponse::error(rpc.id, -32601, format!("Unknown method: {}", rpc.method)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,6 +165,7 @@ async fn sse_tools_call(
|
|||||||
params: Value,
|
params: Value,
|
||||||
progress_token: Value,
|
progress_token: Value,
|
||||||
ctx: Arc<AppContext>,
|
ctx: Arc<AppContext>,
|
||||||
|
session_id: String,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
use tokio::sync::mpsc::unbounded_channel;
|
use tokio::sync::mpsc::unbounded_channel;
|
||||||
|
|
||||||
@@ -174,9 +188,13 @@ async fn sse_tools_call(
|
|||||||
// its final state to the CRDT even on client disconnect).
|
// its final state to the CRDT even on client disconnect).
|
||||||
let dispatch_ctx = Arc::clone(&ctx);
|
let dispatch_ctx = Arc::clone(&ctx);
|
||||||
let dispatch_handle = tokio::spawn(async move {
|
let dispatch_handle = tokio::spawn(async move {
|
||||||
progress::EMITTER
|
session::SESSION_ID
|
||||||
.scope(emitter, async move {
|
.scope(session_id, async move {
|
||||||
dispatch::dispatch_tool_call(&tool_name, args, &dispatch_ctx).await
|
progress::EMITTER
|
||||||
|
.scope(emitter, async move {
|
||||||
|
dispatch::dispatch_tool_call(&tool_name, args, &dispatch_ctx).await
|
||||||
|
})
|
||||||
|
.await
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//! Task-local session identifier for the requesting MCP client.
|
||||||
|
//!
|
||||||
|
//! Threaded ambiently through tool dispatch (same pattern as
|
||||||
|
//! [`super::progress::EMITTER`]) so a deeply-nested handler — currently only
|
||||||
|
//! `tool_prompt_permission` — can scope decisions per requesting agent
|
||||||
|
//! without adding a parameter to `dispatch_tool_call` and every one of its
|
||||||
|
//! ~40 match arms.
|
||||||
|
//!
|
||||||
|
//! The HTTP MCP handler installs the scope before dispatching a `tools/call`
|
||||||
|
//! request, populated from the `X-Huskies-Session` header that per-story
|
||||||
|
//! worktrees embed in their `.mcp.json` (see `worktree::write_mcp_json`).
|
||||||
|
//! Callers with no header (the main interactive chat CLI, API-based runtimes
|
||||||
|
//! that invoke `dispatch_tool_call` directly) fall back to a fixed
|
||||||
|
//! `"default"` key — there is only ever one such session per server process,
|
||||||
|
//! so no cross-story leakage results from sharing that bucket.
|
||||||
|
|
||||||
|
/// Session key used when no `X-Huskies-Session` header was present.
|
||||||
|
pub const DEFAULT_SESSION: &str = "default";
|
||||||
|
|
||||||
|
tokio::task_local! {
|
||||||
|
/// Set by the MCP HTTP handler before dispatching a `tools/call` request.
|
||||||
|
/// Unset in tests and in non-HTTP dispatch paths, where [`current`] falls
|
||||||
|
/// back to [`DEFAULT_SESSION`].
|
||||||
|
pub static SESSION_ID: String;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the current request's session id, or [`DEFAULT_SESSION`] if no
|
||||||
|
/// scope is installed.
|
||||||
|
pub fn current() -> String {
|
||||||
|
SESSION_ID
|
||||||
|
.try_with(Clone::clone)
|
||||||
|
.unwrap_or_else(|_| DEFAULT_SESSION.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn current_falls_back_to_default_without_scope() {
|
||||||
|
assert_eq!(current(), DEFAULT_SESSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn current_reads_installed_scope() {
|
||||||
|
let value = SESSION_ID
|
||||||
|
.scope("1218".to_string(), async { current() })
|
||||||
|
.await;
|
||||||
|
assert_eq!(value, "1218");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ use serde_json::json;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use super::scaffold::scaffold_story_kit;
|
use super::scaffold::{ensure_gitignore_entries, scaffold_story_kit};
|
||||||
|
|
||||||
const KEY_LAST_PROJECT: &str = "last_project_path";
|
const KEY_LAST_PROJECT: &str = "last_project_path";
|
||||||
const KEY_KNOWN_PROJECTS: &str = "known_projects";
|
const KEY_KNOWN_PROJECTS: &str = "known_projects";
|
||||||
@@ -36,6 +36,10 @@ pub(crate) async fn ensure_project_root_with_story_kit(
|
|||||||
}
|
}
|
||||||
if !path.join(".huskies").is_dir() {
|
if !path.join(".huskies").is_dir() {
|
||||||
scaffold_story_kit(&path, port)?;
|
scaffold_story_kit(&path, port)?;
|
||||||
|
} else {
|
||||||
|
// Already-adopted project: pick up any Story Kit gitignore entries
|
||||||
|
// added since this project was first scaffolded.
|
||||||
|
ensure_gitignore_entries(&path)?;
|
||||||
}
|
}
|
||||||
// Always update .mcp.json with the current port so the bot connects to
|
// Always update .mcp.json with the current port so the bot connects to
|
||||||
// the right endpoint even when HUSKIES_PORT changes between restarts.
|
// the right endpoint even when HUSKIES_PORT changes between restarts.
|
||||||
@@ -449,6 +453,34 @@ mod tests {
|
|||||||
assert!(project_dir.join(".huskies").is_dir());
|
assert!(project_dir.join(".huskies").is_dir());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression test for story 1219: projects scaffolded before
|
||||||
|
/// `merge_reports/` and `session_store.json` were added to the ignore
|
||||||
|
/// list must pick up those entries the next time the project is opened,
|
||||||
|
/// without needing to be re-scaffolded from scratch.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn open_project_retrofits_gitignore_entries_for_existing_project() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let project_dir = dir.path().join("myproject");
|
||||||
|
let sk_dir = project_dir.join(".huskies");
|
||||||
|
fs::create_dir_all(&sk_dir).unwrap();
|
||||||
|
fs::write(sk_dir.join(".gitignore"), "worktrees/\n").unwrap();
|
||||||
|
let store = make_store(&dir);
|
||||||
|
let state = SessionState::default();
|
||||||
|
|
||||||
|
open_project(
|
||||||
|
project_dir.to_string_lossy().to_string(),
|
||||||
|
&state,
|
||||||
|
&store,
|
||||||
|
3001,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let content = fs::read_to_string(sk_dir.join(".gitignore")).unwrap();
|
||||||
|
assert!(content.contains("merge_reports/"));
|
||||||
|
assert!(content.contains("session_store.json"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn open_project_does_not_overwrite_existing_story_kit() {
|
async fn open_project_does_not_overwrite_existing_story_kit() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ pub(super) fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
|
|||||||
"store.json",
|
"store.json",
|
||||||
"pipeline.db",
|
"pipeline.db",
|
||||||
"*.db",
|
"*.db",
|
||||||
|
"merge_reports/",
|
||||||
|
"session_store.json",
|
||||||
];
|
];
|
||||||
|
|
||||||
let gitignore_path = root.join(".huskies").join(".gitignore");
|
let gitignore_path = root.join(".huskies").join(".gitignore");
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ use templates::{
|
|||||||
STORY_KIT_STACK,
|
STORY_KIT_STACK,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Retrofit the Story Kit `.gitignore` entries onto a project that was
|
||||||
|
/// scaffolded before those entries existed (e.g. `merge_reports/` or
|
||||||
|
/// `session_store.json` added in a later release). Idempotent — only
|
||||||
|
/// appends lines that are missing, so it is safe to call on every project
|
||||||
|
/// open regardless of how old the project's `.huskies/` directory is.
|
||||||
|
pub(crate) fn ensure_gitignore_entries(root: &Path) -> Result<(), String> {
|
||||||
|
write_story_kit_gitignore(root)?;
|
||||||
|
append_root_gitignore_entries(root)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> {
|
pub(crate) fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> {
|
||||||
let story_kit_root = root.join(".huskies");
|
let story_kit_root = root.join(".huskies");
|
||||||
let specs_root = story_kit_root.join("specs");
|
let specs_root = story_kit_root.join("specs");
|
||||||
|
|||||||
@@ -334,6 +334,31 @@ fn scaffold_creates_story_kit_gitignore_with_relative_entries() {
|
|||||||
// Database files must be ignored so novice users don't accidentally commit them
|
// Database files must be ignored so novice users don't accidentally commit them
|
||||||
assert!(sk_content.contains("pipeline.db"));
|
assert!(sk_content.contains("pipeline.db"));
|
||||||
assert!(sk_content.contains("*.db"));
|
assert!(sk_content.contains("*.db"));
|
||||||
|
// Runtime artifacts written under .huskies/ must be ignored too (story 1219)
|
||||||
|
assert!(sk_content.contains("merge_reports/"));
|
||||||
|
assert!(sk_content.contains("session_store.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_gitignore_entries_retrofits_already_adopted_project() {
|
||||||
|
// Simulate a project scaffolded before merge_reports/ and session_store.json
|
||||||
|
// were added to the ignore list: .huskies/.gitignore exists but lacks them.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
dir.path().join(".huskies/.gitignore"),
|
||||||
|
"worktrees/\ncoverage/\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
ensure_gitignore_entries(dir.path()).unwrap();
|
||||||
|
|
||||||
|
let sk_content = fs::read_to_string(dir.path().join(".huskies/.gitignore")).unwrap();
|
||||||
|
assert!(sk_content.contains("merge_reports/"));
|
||||||
|
assert!(sk_content.contains("session_store.json"));
|
||||||
|
// Pre-existing entries must survive untouched
|
||||||
|
assert!(sk_content.contains("worktrees/"));
|
||||||
|
assert!(sk_content.contains("coverage/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|c| c.permission_timeout_secs)
|
.map(|c| c.permission_timeout_secs)
|
||||||
.unwrap_or(120),
|
.unwrap_or(120),
|
||||||
|
remembered_permissions: service::permission_router::RememberedPermissions::new(),
|
||||||
status: agents.status_broadcaster(),
|
status: agents.status_broadcaster(),
|
||||||
chat_dispatcher: std::sync::Arc::new(chat::dispatcher::ChatDispatcher::new(
|
chat_dispatcher: std::sync::Arc::new(chat::dispatcher::ChatDispatcher::new(
|
||||||
bot_cfg
|
bot_cfg
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ pub(super) fn call_sync(
|
|||||||
permission_registry: ResponderRegistry::new(),
|
permission_registry: ResponderRegistry::new(),
|
||||||
pending_perm_replies: PendingPermReplies::new(),
|
pending_perm_replies: PendingPermReplies::new(),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
|
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||||
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
//! Diagnostics I/O — the ONLY place in `service::diagnostics/` that may perform side effects.
|
|
||||||
//!
|
|
||||||
//! Side effects here include: reading and writing `.claude/settings.json` via `std::fs`.
|
|
||||||
//! Pure permission-rule logic (pattern derivation, wildcard domination checks) lives in
|
|
||||||
//! `permission.rs`.
|
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
/// Add a permission rule to `.claude/settings.json` in the project root.
|
|
||||||
///
|
|
||||||
/// Does nothing if the rule already exists (exact match) or is already covered
|
|
||||||
/// by a wildcard pattern in the allow list. Creates the file and any missing
|
|
||||||
/// parent directories if they do not yet exist.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// Returns `Err(String)` if the directory cannot be created, the file cannot be
|
|
||||||
/// read or written, or the JSON cannot be parsed or serialised.
|
|
||||||
pub fn add_permission_rule(project_root: &Path, rule: &str) -> Result<(), String> {
|
|
||||||
let claude_dir = project_root.join(".claude");
|
|
||||||
fs::create_dir_all(&claude_dir)
|
|
||||||
.map_err(|e| format!("Failed to create .claude/ directory: {e}"))?;
|
|
||||||
|
|
||||||
let settings_path = claude_dir.join("settings.json");
|
|
||||||
let mut settings: Value = if settings_path.exists() {
|
|
||||||
let content = fs::read_to_string(&settings_path)
|
|
||||||
.map_err(|e| format!("Failed to read settings.json: {e}"))?;
|
|
||||||
serde_json::from_str(&content).map_err(|e| format!("Failed to parse settings.json: {e}"))?
|
|
||||||
} else {
|
|
||||||
json!({ "permissions": { "allow": [] } })
|
|
||||||
};
|
|
||||||
|
|
||||||
let allow_arr = settings
|
|
||||||
.pointer_mut("/permissions/allow")
|
|
||||||
.and_then(|v| v.as_array_mut());
|
|
||||||
|
|
||||||
let allow = match allow_arr {
|
|
||||||
Some(arr) => arr,
|
|
||||||
None => {
|
|
||||||
settings
|
|
||||||
.as_object_mut()
|
|
||||||
.unwrap()
|
|
||||||
.entry("permissions")
|
|
||||||
.or_insert(json!({ "allow": [] }));
|
|
||||||
settings
|
|
||||||
.pointer_mut("/permissions/allow")
|
|
||||||
.unwrap()
|
|
||||||
.as_array_mut()
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let rule_value = Value::String(rule.to_string());
|
|
||||||
|
|
||||||
// Exact duplicate check.
|
|
||||||
if allow.contains(&rule_value) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wildcard-coverage check: if "mcp__huskies__*" exists, skip more-specific rules.
|
|
||||||
if super::permission::is_dominated_by_wildcard(rule, allow) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
allow.push(rule_value);
|
|
||||||
|
|
||||||
let pretty =
|
|
||||||
serde_json::to_string_pretty(&settings).map_err(|e| format!("Failed to serialize: {e}"))?;
|
|
||||||
fs::write(&settings_path, pretty).map_err(|e| format!("Failed to write settings.json: {e}"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -3,20 +3,17 @@
|
|||||||
//! Extracted from `http/mcp/diagnostics.rs` following the conventions in
|
//! Extracted from `http/mcp/diagnostics.rs` following the conventions in
|
||||||
//! `docs/architecture/service-modules.md`:
|
//! `docs/architecture/service-modules.md`:
|
||||||
//! - `mod.rs` (this file) — public API, typed [`Error`], orchestration
|
//! - `mod.rs` (this file) — public API, typed [`Error`], orchestration
|
||||||
//! - `io.rs` — the ONLY place that performs side effects (filesystem reads/writes)
|
|
||||||
//! - `permission.rs` — pure permission-rule generation and wildcard checks
|
//! - `permission.rs` — pure permission-rule generation and wildcard checks
|
||||||
|
//!
|
||||||
|
//! Permission rules are remembered in-memory, per requesting-agent session
|
||||||
|
//! (`service::permission_router::RememberedPermissions`, story 1218) rather
|
||||||
|
//! than written to disk, so there is no side-effectful I/O submodule here.
|
||||||
|
|
||||||
/// Side-effectful diagnostics I/O — log reads, CRDT dumps, filesystem writes.
|
|
||||||
pub mod io;
|
|
||||||
/// Pure permission-rule generation and wildcard matching.
|
/// Pure permission-rule generation and wildcard matching.
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use io::add_permission_rule;
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use permission::generate_permission_rule;
|
pub use permission::generate_permission_rule;
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use permission::is_dominated_by_wildcard;
|
|
||||||
|
|
||||||
// ── Error type ────────────────────────────────────────────────────────────────
|
// ── Error type ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -22,21 +22,6 @@ pub fn generate_permission_rule(tool_name: &str, tool_input: &Value) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return `true` if `rule` is already covered by an existing wildcard in `allow_list`.
|
|
||||||
///
|
|
||||||
/// For example, if `allow_list` contains `"mcp__huskies__*"`, then the more
|
|
||||||
/// specific rule `"mcp__huskies__create_story"` is already covered.
|
|
||||||
pub fn is_dominated_by_wildcard(rule: &str, allow_list: &[Value]) -> bool {
|
|
||||||
allow_list.iter().any(|existing| {
|
|
||||||
if let Some(pat) = existing.as_str()
|
|
||||||
&& let Some(prefix) = pat.strip_suffix('*')
|
|
||||||
{
|
|
||||||
return rule.starts_with(prefix);
|
|
||||||
}
|
|
||||||
false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -79,27 +64,4 @@ mod tests {
|
|||||||
let rule = generate_permission_rule("mcp__huskies__create_story", &json!({"name": "foo"}));
|
let rule = generate_permission_rule("mcp__huskies__create_story", &json!({"name": "foo"}));
|
||||||
assert_eq!(rule, "mcp__huskies__create_story");
|
assert_eq!(rule, "mcp__huskies__create_story");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_dominated_by_exact_wildcard() {
|
|
||||||
let allow = vec![json!("mcp__huskies__*")];
|
|
||||||
assert!(is_dominated_by_wildcard(
|
|
||||||
"mcp__huskies__create_story",
|
|
||||||
&allow
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_not_dominated_by_different_prefix() {
|
|
||||||
let allow = vec![json!("mcp__other__*")];
|
|
||||||
assert!(!is_dominated_by_wildcard(
|
|
||||||
"mcp__huskies__create_story",
|
|
||||||
&allow
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_not_dominated_when_list_is_empty() {
|
|
||||||
assert!(!is_dominated_by_wildcard("Edit", &[]));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -741,6 +741,7 @@ pub fn spawn_gateway_bot(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|c| c.permission_timeout_secs)
|
.map(|c| c.permission_timeout_secs)
|
||||||
.unwrap_or(120),
|
.unwrap_or(120),
|
||||||
|
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||||
chat_dispatcher: std::sync::Arc::new(crate::chat::dispatcher::ChatDispatcher::new(
|
chat_dispatcher: std::sync::Arc::new(crate::chat::dispatcher::ChatDispatcher::new(
|
||||||
bot_cfg
|
bot_cfg
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
//! part of the server.
|
//! part of the server.
|
||||||
|
|
||||||
use crate::http::context::{PermissionDecision, PermissionForward};
|
use crate::http::context::{PermissionDecision, PermissionForward};
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::Mutex as StdMutex;
|
use std::sync::Mutex as StdMutex;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
@@ -241,6 +241,57 @@ impl PendingPermReplies {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Remembered permissions ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// In-memory "don't ask again for this tool + target-pattern" store, scoped
|
||||||
|
/// per requesting agent session (story 1218).
|
||||||
|
///
|
||||||
|
/// Keyed by session id (see `http::mcp::session`) — for a per-story coding
|
||||||
|
/// agent this is the story id, stable across a `--resume` of the same story
|
||||||
|
/// since the worktree (and its `.mcp.json` header) doesn't change. Rules
|
||||||
|
/// remembered here are visible only within the session that recorded them,
|
||||||
|
/// so one agent's "don't ask again" never widens what another agent or
|
||||||
|
/// story is auto-approved for.
|
||||||
|
///
|
||||||
|
/// Deliberately **not** persisted to disk: this store lives only as long as
|
||||||
|
/// the server process does. Restarting the server clears every remembered
|
||||||
|
/// rule (agents will be prompted again once), which is an explicit tradeoff
|
||||||
|
/// — durability across restarts would require writing into each worktree's
|
||||||
|
/// own `.claude/settings.json`, which adds a second persistence path for a
|
||||||
|
/// case (server restarts mid-story) rare enough not to justify the added
|
||||||
|
/// complexity.
|
||||||
|
pub struct RememberedPermissions {
|
||||||
|
inner: StdMutex<HashMap<String, HashSet<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RememberedPermissions {
|
||||||
|
/// Create an empty store.
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
inner: StdMutex::new(HashMap::new()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that `rule` is approved for `session_id` going forward.
|
||||||
|
pub fn remember(&self, session_id: &str, rule: &str) {
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entry(session_id.to_string())
|
||||||
|
.or_default()
|
||||||
|
.insert(rule.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` if `rule` was previously remembered for `session_id`.
|
||||||
|
pub fn is_remembered(&self, session_id: &str, rule: &str) -> bool {
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(session_id)
|
||||||
|
.is_some_and(|rules| rules.contains(rule))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -394,4 +445,36 @@ mod tests {
|
|||||||
let pending = PendingPermReplies::new();
|
let pending = PendingPermReplies::new();
|
||||||
assert!(pending.resolve_oldest("no-such-room").await.is_none());
|
assert!(pending.resolve_oldest("no-such-room").await.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── RememberedPermissions ───────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remembered_permissions_starts_empty() {
|
||||||
|
let store = RememberedPermissions::new();
|
||||||
|
assert!(!store.is_remembered("1218", "Bash(git *)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remembered_permissions_recalls_after_remember() {
|
||||||
|
let store = RememberedPermissions::new();
|
||||||
|
store.remember("1218", "Bash(git *)");
|
||||||
|
assert!(store.is_remembered("1218", "Bash(git *)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remembered_permissions_scoped_per_session() {
|
||||||
|
let store = RememberedPermissions::new();
|
||||||
|
store.remember("1218", "Bash(git *)");
|
||||||
|
assert!(
|
||||||
|
!store.is_remembered("1216", "Bash(git *)"),
|
||||||
|
"a rule remembered for one story must not apply to another"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remembered_permissions_scoped_per_rule() {
|
||||||
|
let store = RememberedPermissions::new();
|
||||||
|
store.remember("1218", "Bash(git *)");
|
||||||
|
assert!(!store.is_remembered("1218", "Write"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
|
|
||||||
use crate::agents::AgentPool;
|
use crate::agents::AgentPool;
|
||||||
use crate::chat::dispatcher::ChatDispatcher;
|
use crate::chat::dispatcher::ChatDispatcher;
|
||||||
use crate::service::permission_router::{PendingPermReplies, ResponderRegistry};
|
use crate::service::permission_router::{
|
||||||
|
PendingPermReplies, RememberedPermissions, ResponderRegistry,
|
||||||
|
};
|
||||||
use crate::service::status::StatusBroadcaster;
|
use crate::service::status::StatusBroadcaster;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -41,6 +43,11 @@ pub struct Services {
|
|||||||
/// Seconds to wait for a user to respond to a permission prompt before
|
/// Seconds to wait for a user to respond to a permission prompt before
|
||||||
/// auto-denying (fail-closed).
|
/// auto-denying (fail-closed).
|
||||||
pub permission_timeout_secs: u64,
|
pub permission_timeout_secs: u64,
|
||||||
|
/// In-memory, per-session "don't ask again" permission rules (story
|
||||||
|
/// 1218). Checked by `tool_prompt_permission` before forwarding a
|
||||||
|
/// request to chat; never persisted to disk and never affects a
|
||||||
|
/// different session's agent.
|
||||||
|
pub remembered_permissions: Arc<RememberedPermissions>,
|
||||||
/// Project-scoped status broadcaster.
|
/// Project-scoped status broadcaster.
|
||||||
///
|
///
|
||||||
/// Consumers (chat transports, Web UI, agent context) call
|
/// Consumers (chat transports, Web UI, agent context) call
|
||||||
@@ -73,6 +80,7 @@ impl Services {
|
|||||||
permission_registry: ResponderRegistry::new(),
|
permission_registry: ResponderRegistry::new(),
|
||||||
pending_perm_replies: PendingPermReplies::new(),
|
pending_perm_replies: PendingPermReplies::new(),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
|
remembered_permissions: RememberedPermissions::new(),
|
||||||
chat_dispatcher: std::sync::Arc::new(ChatDispatcher::new(1_500)),
|
chat_dispatcher: std::sync::Arc::new(ChatDispatcher::new(1_500)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -483,6 +483,7 @@ mod tests {
|
|||||||
permission_registry: ResponderRegistry::new(),
|
permission_registry: ResponderRegistry::new(),
|
||||||
pending_perm_replies: PendingPermReplies::new(),
|
pending_perm_replies: PendingPermReplies::new(),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
|
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ pub async fn create_worktree(
|
|||||||
tokio::task::spawn_blocking(move || configure_sparse_checkout(&wt_clone))
|
tokio::task::spawn_blocking(move || configure_sparse_checkout(&wt_clone))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("spawn_blocking: {e}"))??;
|
.map_err(|e| format!("spawn_blocking: {e}"))??;
|
||||||
write_mcp_json(&wt_path, port)?;
|
write_mcp_json(&wt_path, port, story_id)?;
|
||||||
return Ok(WorktreeInfo {
|
return Ok(WorktreeInfo {
|
||||||
path: wt_path,
|
path: wt_path,
|
||||||
branch,
|
branch,
|
||||||
@@ -68,7 +68,7 @@ pub async fn create_worktree(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("spawn_blocking: {e}"))??;
|
.map_err(|e| format!("spawn_blocking: {e}"))??;
|
||||||
|
|
||||||
write_mcp_json(&wt_path, port)?;
|
write_mcp_json(&wt_path, port, story_id)?;
|
||||||
run_setup_commands(&wt_path, config).await;
|
run_setup_commands(&wt_path, config).await;
|
||||||
|
|
||||||
Ok(WorktreeInfo {
|
Ok(WorktreeInfo {
|
||||||
|
|||||||
@@ -40,10 +40,23 @@ pub fn worktree_path(project_root: &Path, story_id: &str) -> PathBuf {
|
|||||||
|
|
||||||
/// Write a `.mcp.json` file in the given directory pointing to the huskies
|
/// Write a `.mcp.json` file in the given directory pointing to the huskies
|
||||||
/// HTTP MCP endpoint at the given port.
|
/// HTTP MCP endpoint at the given port.
|
||||||
pub fn write_mcp_json(dir: &Path, port: u16) -> Result<(), String> {
|
///
|
||||||
let content = format!(
|
/// Embeds an `X-Huskies-Session` header set to `story_id` so the server can
|
||||||
"{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n"
|
/// scope remembered permission approvals to this worktree's agent session
|
||||||
);
|
/// (see `http::mcp::session`) without affecting other stories' agents.
|
||||||
|
pub fn write_mcp_json(dir: &Path, port: u16, story_id: &str) -> Result<(), String> {
|
||||||
|
let value = serde_json::json!({
|
||||||
|
"mcpServers": {
|
||||||
|
"huskies": {
|
||||||
|
"type": "http",
|
||||||
|
"url": format!("http://localhost:{port}/mcp"),
|
||||||
|
"headers": { "X-Huskies-Session": story_id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let content = serde_json::to_string_pretty(&value)
|
||||||
|
.map_err(|e| format!("Serialize .mcp.json: {e}"))?
|
||||||
|
+ "\n";
|
||||||
std::fs::write(dir.join(".mcp.json"), content).map_err(|e| format!("Write .mcp.json: {e}"))
|
std::fs::write(dir.join(".mcp.json"), content).map_err(|e| format!("Write .mcp.json: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +104,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn write_mcp_json_uses_given_port() {
|
fn write_mcp_json_uses_given_port() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
write_mcp_json(tmp.path(), 4242).unwrap();
|
write_mcp_json(tmp.path(), 4242, "1218").unwrap();
|
||||||
let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
|
let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
|
||||||
assert!(content.contains("http://localhost:4242/mcp"));
|
assert!(content.contains("http://localhost:4242/mcp"));
|
||||||
}
|
}
|
||||||
@@ -99,11 +112,23 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn write_mcp_json_default_port() {
|
fn write_mcp_json_default_port() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
write_mcp_json(tmp.path(), 3001).unwrap();
|
write_mcp_json(tmp.path(), 3001, "1218").unwrap();
|
||||||
let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
|
let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
|
||||||
assert!(content.contains("http://localhost:3001/mcp"));
|
assert!(content.contains("http://localhost:3001/mcp"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_mcp_json_embeds_session_header_with_story_id() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
write_mcp_json(tmp.path(), 3001, "42_story_test").unwrap();
|
||||||
|
let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
parsed["mcpServers"]["huskies"]["headers"]["X-Huskies-Session"],
|
||||||
|
"42_story_test"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worktree_path_is_inside_project() {
|
fn worktree_path_is_inside_project() {
|
||||||
let project_root = Path::new("/home/user/my-project");
|
let project_root = Path::new("/home/user/my-project");
|
||||||
|
|||||||
Reference in New Issue
Block a user