Synced from monorepo

Changes:
- Non-blocking coding-data sharing upsell banner
- Consolidate remediation in Doctor
- Auto mode defers fail-closed gate asks to the classifier
- Coalesce marketplace list fetches
- Allow removing a marketplace source by name
- Contain hung git marketplace sources (timeouts, non-blocking refresh, unbrick modal)
- Label failed workspace RPCs with error_kind
- Drop redundant explicit tonic/prost deps from xai-grok-shell
- Report real exit codes for completed background shells
- Narrow the date-rollover reminder to date-bearing templates
- Wire toolOverrides through the session and agent
- Security: Bash(git:*) allowlist matches whole command chain by prefix
- Split prompt-trigger telemetry and record classifier provenance
- Raise connectors-manager timeout to 60s
- Auto classifier honors recorded approvals for repeat actions
- Apply doctor fixes in the TUI
- Auto-mode classifier timeouts prompt instead of silently denying
- Scope subagent completion drains to the owning session
- Add the toolOverrides wire types
- Set client_identifier=grok-agent-sdk
- Accept both spellings of the workspace-teleport kill switch
- Persist one-shot occurrence journal
- Stop turns that poll the exact same tool call 16x in a row
- Copy compaction checkpoint files when forking sessions
- Auto-focus permission prompt from scrollback
- Esc cancels the running turn in non-vim and minimal modes
- List Ctrl+Z undo and redo in keyboard shortcuts
- Out-of-process macOS mic capture
- Show active auth mode on session-info
- Install the npm binary under $GROK_HOME
- Remove hover/click dead zones between dashboard items
- Route startup warnings to doctor
- Document [feedback.user] author identity config
- Extend bang command timeout
- Close combine-queued edit-hold race
- Integrate relocation recovery
- Expose privacy notice rollout flag
- Break harness discovery ref cycle so connections can idle-evict
- Shift/Alt+Enter inserts newline when editing a queued prompt
- Gate project Claude permissions on folder trust
- Echo response.create.event_id on response.created
- Toast when session creation fails from disk full
- Add shared test process lifecycle
- Enable dynamic workflows by default
- Add relocation transaction state machine
- Add shared test sandbox
- Surface auth failures on model-switch compact
- Persist durable scheduler expiry
- Confirm before removing extensions-modal items
- Re-run compact and prompt after login when compact hit expired auth
- Recap sends hosted tools under backend search
This commit is contained in:
grokkybara[bot] 2026-07-22 19:18:53 +01:00
commit a5727c5960
482 changed files with 37627 additions and 13402 deletions

View file

@ -65,6 +65,17 @@ static WORKSPACE_RPC_REQUESTS_TOTAL: std::sync::LazyLock<IntCounterVec> =
)
.unwrap()
});
/// Failed `workspace.*` RPC dispatches, by method and
/// [`WorkspaceError::metric_kind`].
static WORKSPACE_RPC_ERRORS_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
register_int_counter_vec!(
"grok_workspace_rpc_errors_total",
"Failed workspace RPC dispatches, by method and error kind",
&["method", "error_kind"]
)
.unwrap()
});
/// Per-method wall-clock duration of a `workspace.*` RPC dispatch.
static WORKSPACE_RPC_DURATION_SECONDS: std::sync::LazyLock<HistogramVec> =
std::sync::LazyLock::new(|| {
@ -88,6 +99,9 @@ pub(crate) fn init_metrics() {
WORKSPACE_RPC_REQUESTS_TOTAL
.with_label_values(&[UNKNOWN_METHOD_LABEL, "error"])
.inc_by(0);
WORKSPACE_RPC_ERRORS_TOTAL
.with_label_values(&[UNKNOWN_METHOD_LABEL, "hub_error"])
.inc_by(0);
let _ = WORKSPACE_RPC_DURATION_SECONDS.with_label_values(&[UNKNOWN_METHOD_LABEL]);
}
/// Resolve the caller identity for a mutation RPC: the server-bound envelope
@ -106,7 +120,9 @@ fn resolve_mutation_caller<'a>(
.with_label_values(&[method, "param_mismatch"])
.inc();
tracing::warn!(
method, envelope_session = % envelope, param_caller = % param,
method,
envelope_session = %envelope,
param_caller = %param,
"caller_session_id param disagrees with the server-bound envelope session; \
trusting the envelope"
);
@ -144,9 +160,7 @@ fn record_mutation_rpc<T>(
match result {
Ok(_) => tracing::info!(method, caller, target, "workspace mutation rpc"),
Err(e) => {
tracing::warn!(
method, caller, target, error = % e, "workspace mutation rpc failed"
);
tracing::warn!(method, caller, target, error = %e, "workspace mutation rpc failed");
}
}
}
@ -377,7 +391,11 @@ impl WorkspaceRpcHandler {
.map(|n| n.to_string_lossy().to_string())
})
.unwrap_or_else(|| "sh".to_string());
Ok(serde_json::json!({ "os" : os, "shell" : shell, "cwd" : cwd_str, }))
Ok(serde_json::json!({
"os": os,
"shell": shell,
"cwd": cwd_str,
}))
}
<GitStatusReq as WorkspaceRpc>::METHOD => {
static DEPRECATION_WARNING: std::sync::Once = std::sync::Once::new();
@ -518,10 +536,12 @@ impl WorkspaceRpcHandler {
} else {
None
};
results.push(serde_json::json!(
{ "path" : full_path.to_string_lossy(), "ref" : ref_path,
"exists" : exists, "content" : content, }
));
results.push(serde_json::json!({
"path": full_path.to_string_lossy(),
"ref": ref_path,
"exists": exists,
"content": content,
}));
}
Ok(Value::Array(results))
}
@ -589,7 +609,7 @@ impl WorkspaceRpcHandler {
}
<LoadPermissionsReq as WorkspaceRpc>::METHOD => {
let cwd = self.workspace.root_cwd()?;
Ok(crate::discovery::load_permissions(&cwd).await)
Ok(crate::discovery::load_permissions(&cwd, true).await)
}
<LoadEnvrcReq as WorkspaceRpc>::METHOD => {
let cwd = self.workspace.root_cwd()?;
@ -906,12 +926,20 @@ impl ToolServerHandler for WorkspaceRpcHandler {
)
}
fn input_schema(&self) -> Option<Value> {
Some(serde_json::json!(
{ "type" : "object", "properties" : { "method" : { "type" : "string",
"description" : "The workspace.* method to invoke" }, "params" : { "type"
: "object", "description" : "Method parameters" } }, "required" :
["method"] }
))
Some(serde_json::json!({
"type": "object",
"properties": {
"method": {
"type": "string",
"description": "The workspace.* method to invoke"
},
"params": {
"type": "object",
"description": "Method parameters"
}
},
"required": ["method"]
}))
}
async fn handle_call(&self, ctx: ToolCallContext, args: Value) -> ToolStream<TypedToolOutput> {
let tool_id = self.tool_id();
@ -939,8 +967,8 @@ impl ToolServerHandler for WorkspaceRpcHandler {
)
.await;
let is_unknown_method = matches!(
& result, Err(WorkspaceError::HubError(msg)) if msg
.starts_with(UNKNOWN_METHOD_ERR_PREFIX)
&result,
Err(WorkspaceError::HubError(msg)) if msg.starts_with(UNKNOWN_METHOD_ERR_PREFIX)
);
let method_label = if is_unknown_method {
UNKNOWN_METHOD_LABEL
@ -950,6 +978,11 @@ impl ToolServerHandler for WorkspaceRpcHandler {
WORKSPACE_RPC_REQUESTS_TOTAL
.with_label_values(&[method_label, if result.is_ok() { "ok" } else { "error" }])
.inc();
if let Err(e) = &result {
WORKSPACE_RPC_ERRORS_TOTAL
.with_label_values(&[method_label, e.metric_kind()])
.inc();
}
WORKSPACE_RPC_DURATION_SECONDS
.with_label_values(&[method_label])
.observe(start.elapsed().as_secs_f64());
@ -965,16 +998,16 @@ impl ToolServerHandler for WorkspaceRpcHandler {
match frame.event {
HookEvent::Cancel => {
if let Some(call_id) = &frame.call_id {
tracing::info!(% session_id, % call_id, "cancel hook received");
tracing::info!(%session_id, %call_id, "cancel hook received");
self.workspace
.cancel_tool_call(session_id.as_str(), call_id.as_str());
} else {
tracing::info!(% session_id, "cancel hook received (session-wide)");
tracing::info!(%session_id, "cancel hook received (session-wide)");
self.workspace.cancel_all_tool_calls(session_id.as_str());
}
}
HookEvent::SessionEnded => {
tracing::info!(% session_id, "session_ended hook received");
tracing::info!(%session_id, "session_ended hook received");
self.workspace
.teardown_session_mcp(session_id.as_str())
.await;
@ -989,14 +1022,17 @@ impl ToolServerHandler for WorkspaceRpcHandler {
match serde_json::from_value::<BeforeTurnPayload>(payload) {
Ok(p) => {
tracing::info!(
session = % session_id, turn = p.turn_number, model = % p
.model_id, "before_turn hook received"
session = %session_id,
turn = p.turn_number,
model = %p.model_id,
"before_turn hook received"
);
self.workspace.on_before_turn(session_id.as_str(), &p).await;
}
Err(e) => {
tracing::warn!(
error = % e, "before_turn payload deserialization failed"
error = %e,
"before_turn payload deserialization failed"
);
}
}
@ -1004,30 +1040,32 @@ impl ToolServerHandler for WorkspaceRpcHandler {
AFTER_TURN_KIND => match serde_json::from_value::<AfterTurnPayload>(payload) {
Ok(p) => {
tracing::info!(
session = % session_id, turn = p.turn_number, outcome = ? p
.outcome, duration_ms = p.duration_ms,
session = %session_id,
turn = p.turn_number,
outcome = ?p.outcome,
duration_ms = p.duration_ms,
"after_turn hook received"
);
self.workspace.on_after_turn(session_id.as_str(), &p).await;
}
Err(e) => {
tracing::warn!(
error = % e, "after_turn payload deserialization failed"
error = %e,
"after_turn payload deserialization failed"
);
}
},
_ => {
tracing::debug!(
kind = % kind, session = % session_id,
kind = %kind,
session = %session_id,
"unrecognized custom hook kind"
);
}
}
}
HookEvent::Pause | HookEvent::Resume => {
tracing::debug!(
% session_id, event = ? frame.event, "hook not yet implemented"
);
tracing::debug!(%session_id, event = ?frame.event, "hook not yet implemented");
}
}
}
@ -1048,7 +1086,7 @@ impl ToolServerHandler for WorkspaceRpcHandler {
let request: TurnHookRequest = match serde_json::from_value(payload) {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = % e, % session_id, "invalid turn hook request");
tracing::warn!(error = %e, %session_id, "invalid turn hook request");
return no_op();
}
};
@ -1092,12 +1130,14 @@ impl ToolServerHandler for WorkspaceRpcHandler {
if !start_drain {
if became_empty {
tracing::info!(
session = % params.session_id, reason = % params.reason,
session = %params.session_id,
reason = %params.reason,
"workspace: hub evict — already draining/shutting down; dropped session only"
);
} else {
tracing::info!(
session = % params.session_id, reason = % params.reason,
session = %params.session_id,
reason = %params.reason,
"workspace: hub evict — other sessions live; dropped session only"
);
}
@ -1105,8 +1145,9 @@ impl ToolServerHandler for WorkspaceRpcHandler {
}
let grace = std::time::Duration::from_millis(params.grace_period_ms);
tracing::info!(
session = % params.session_id, reason = % params.reason, grace_period_ms =
params.grace_period_ms,
session = %params.session_id,
reason = %params.reason,
grace_period_ms = params.grace_period_ms,
"workspace: hub evict — last session; commencing two-phase drain"
);
let unfinished = self
@ -1115,7 +1156,8 @@ impl ToolServerHandler for WorkspaceRpcHandler {
.await;
if unfinished > 0 {
tracing::warn!(
session = % params.session_id, unfinished,
session = %params.session_id,
unfinished,
"workspace: hub evict drain left items pending"
);
}
@ -1212,8 +1254,9 @@ mod tests {
let result = handler
.dispatch("workspace.nonexistent", Value::Null, None)
.await;
assert!(matches!(result, Err(WorkspaceError::HubError(msg)) if msg
.contains("unknown workspace method")));
assert!(
matches!(result, Err(WorkspaceError::HubError(msg)) if msg.contains("unknown workspace method"))
);
}
/// A hub evict runs the two-phase drain then settles into terminal
/// ShuttingDown (not a lingering Draining) for an evicted workspace.
@ -1298,7 +1341,7 @@ mod tests {
let value = handler
.dispatch(
"workspace.list_background_tasks",
serde_json::json!({ "session_id" : "bg-rpc" }),
serde_json::json!({"session_id": "bg-rpc"}),
Some("bg-rpc"),
)
.await
@ -1381,7 +1424,7 @@ mod tests {
let value = handler
.dispatch(
"workspace.tasks_snapshot",
serde_json::json!({ "session_id" : "snap-rpc" }),
serde_json::json!({"session_id": "snap-rpc"}),
Some("snap-rpc"),
)
.await
@ -1537,7 +1580,7 @@ mod tests {
async fn dispatch_tool_definitions_returns_known_tools() {
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle);
let params = serde_json::json!({ "session_id" : "main" });
let params = serde_json::json!({"session_id": "main"});
let result = handler
.dispatch("workspace.tool_definitions", params, None)
.await;
@ -1561,7 +1604,7 @@ mod tests {
async fn dispatch_tool_definitions_unknown_session() {
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle);
let params = serde_json::json!({ "session_id" : "ghost" });
let params = serde_json::json!({"session_id": "ghost"});
let result = handler
.dispatch("workspace.tool_definitions", params, None)
.await;
@ -1614,9 +1657,7 @@ mod tests {
async fn dispatch_drop_session_self_succeeds() {
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle.clone());
let params = serde_json::json!(
{ "caller_session_id" : "main", "session_id" : "main" }
);
let params = serde_json::json!({"caller_session_id": "main", "session_id": "main"});
let result = handler
.dispatch("workspace.drop_session", params, None)
.await;
@ -1631,8 +1672,7 @@ mod tests {
.dispatch("workspace.update_tool_config", serde_json::json!({}), None)
.await;
assert!(
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg
.contains("missing")),
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing")),
"got {result:?}"
);
}
@ -1653,10 +1693,11 @@ mod tests {
let mismatch_before = caller_mismatch_count("update_tool_config", "param_mismatch");
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle);
let params = serde_json::json!(
{ "caller_session_id" : "spoofed", "session_id" : "main", "new_config" :
baseline_config_value(), }
);
let params = serde_json::json!({
"caller_session_id": "spoofed",
"session_id": "main",
"new_config": baseline_config_value(),
});
let result = handler
.dispatch("workspace.update_tool_config", params, Some("main"))
.await;
@ -1676,10 +1717,11 @@ mod tests {
async fn dispatch_update_tool_config_envelope_cross_session_unauthorized() {
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle.clone());
let params = serde_json::json!(
{ "caller_session_id" : "main", "session_id" : "main", "new_config" :
baseline_config_value(), }
);
let params = serde_json::json!({
"caller_session_id": "main",
"session_id": "main",
"new_config": baseline_config_value(),
});
let result = handler
.dispatch("workspace.update_tool_config", params, Some("other"))
.await;
@ -1700,10 +1742,11 @@ mod tests {
let absent_before = caller_mismatch_count("update_tool_config", "envelope_absent");
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle);
let params = serde_json::json!(
{ "caller_session_id" : "main", "session_id" : "main", "new_config" :
baseline_config_value(), }
);
let params = serde_json::json!({
"caller_session_id": "main",
"session_id": "main",
"new_config": baseline_config_value(),
});
let result = handler
.dispatch("workspace.update_tool_config", params, None)
.await;
@ -1723,9 +1766,10 @@ mod tests {
async fn dispatch_update_tool_config_envelope_only_without_param() {
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle);
let params = serde_json::json!(
{ "session_id" : "main", "new_config" : baseline_config_value(), }
);
let params = serde_json::json!({
"session_id": "main",
"new_config": baseline_config_value(),
});
let result = handler
.dispatch("workspace.update_tool_config", params, Some("main"))
.await;
@ -1769,9 +1813,7 @@ mod tests {
.get();
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle.clone());
let params = serde_json::json!(
{ "caller_session_id" : "spoofed", "session_id" : "main" }
);
let params = serde_json::json!({"caller_session_id": "spoofed", "session_id": "main"});
let result = handler
.dispatch("workspace.drop_session", params, Some("main"))
.await;
@ -1791,9 +1833,7 @@ mod tests {
async fn dispatch_drop_session_envelope_cross_session_unauthorized() {
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle.clone());
let params = serde_json::json!(
{ "caller_session_id" : "main", "session_id" : "main" }
);
let params = serde_json::json!({"caller_session_id": "main", "session_id": "main"});
let result = handler
.dispatch("workspace.drop_session", params, Some("observer-ish"))
.await;
@ -1815,7 +1855,7 @@ mod tests {
let _ = handler
.dispatch(
"workspace.configure_mcp",
serde_json::json!({ "mcp_servers" : [] }),
serde_json::json!({"mcp_servers": []}),
Some("mcp-fresh"),
)
.await;
@ -1831,9 +1871,9 @@ mod tests {
async fn dispatch_hunk_action_unknown_action() {
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle);
let params = serde_json::json!(
{ "action" : { "hunk_id" : "test-id", "action" : "dance" } }
);
let params = serde_json::json!({
"action": {"hunk_id": "test-id", "action": "dance"}
});
let result = handler
.dispatch("workspace.hunk_action", params, None)
.await;
@ -1846,7 +1886,9 @@ mod tests {
async fn dispatch_hunk_action_malformed_json() {
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle);
let params = serde_json::json!({ "action" : "not-an-object" });
let params = serde_json::json!({
"action": "not-an-object"
});
let result = handler
.dispatch("workspace.hunk_action", params, None)
.await;
@ -1864,8 +1906,7 @@ mod tests {
.dispatch("workspace.hunk_action", params, None)
.await;
assert!(
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg
.contains("missing field")),
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing field")),
"got {result:?}"
);
}
@ -1873,13 +1914,12 @@ mod tests {
async fn dispatch_hunk_file_action_missing_path() {
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle);
let params = serde_json::json!({ "action" : "accept" });
let params = serde_json::json!({"action": "accept"});
let result = handler
.dispatch("workspace.hunk_file_action", params, None)
.await;
assert!(
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg
.contains("missing field")),
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing field")),
"got {result:?}"
);
}
@ -1887,13 +1927,12 @@ mod tests {
async fn dispatch_hunk_turn_action_missing_prompt_index() {
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle);
let params = serde_json::json!({ "action" : "accept" });
let params = serde_json::json!({"action": "accept"});
let result = handler
.dispatch("workspace.hunk_turn_action", params, None)
.await;
assert!(
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg
.contains("missing field")),
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing field")),
"got {result:?}"
);
}
@ -1901,7 +1940,7 @@ mod tests {
async fn dispatch_hunk_all_action_invalid_action() {
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle);
let params = serde_json::json!({ "action" : "explode" });
let params = serde_json::json!({"action": "explode"});
let result = handler
.dispatch("workspace.hunk_all_action", params, None)
.await;
@ -1941,7 +1980,7 @@ mod tests {
let result = handler
.dispatch(
"workspace.fuzzy_open",
serde_json::json!({ "hidden" : false }),
serde_json::json!({"hidden": false}),
None,
)
.await;
@ -1958,7 +1997,7 @@ mod tests {
let result = handler
.dispatch(
"workspace.fuzzy_close",
serde_json::json!({ "search_id" : "nonexistent" }),
serde_json::json!({"search_id": "nonexistent"}),
None,
)
.await;
@ -1972,13 +2011,12 @@ mod tests {
let result = handler
.dispatch(
"workspace.fuzzy_change",
serde_json::json!({ "query" : "test" }),
serde_json::json!({"query": "test"}),
None,
)
.await;
assert!(
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg
.contains("missing field")),
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing field")),
"got {result:?}"
);
}
@ -1990,8 +2028,7 @@ mod tests {
.dispatch("workspace.fuzzy_search", serde_json::json!({}), None)
.await;
assert!(
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg
.contains("missing search_id")),
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing search_id")),
"got {result:?}"
);
}
@ -2002,7 +2039,7 @@ mod tests {
let open_result = handler
.dispatch(
"workspace.fuzzy_open",
serde_json::json!({ "hidden" : false }),
serde_json::json!({"hidden": false}),
None,
)
.await
@ -2014,7 +2051,7 @@ mod tests {
let close_result = handler
.dispatch(
"workspace.fuzzy_close",
serde_json::json!({ "search_id" : search_id }),
serde_json::json!({"search_id": search_id}),
None,
)
.await
@ -2027,7 +2064,7 @@ mod tests {
let close_again = handler
.dispatch(
"workspace.fuzzy_close",
serde_json::json!({ "search_id" : search_id }),
serde_json::json!({"search_id": search_id}),
None,
)
.await
@ -2045,9 +2082,10 @@ mod tests {
let mut ctx = ToolCallContext::default();
ctx.extensions
.insert(xai_tool_runtime::SessionContext("main".to_owned()));
let args = serde_json::json!(
{ "method" : "workspace.get_session_summary", "params" : {} }
);
let args = serde_json::json!({
"method": "workspace.get_session_summary",
"params": {}
});
let mut stream = handler.handle_call(ctx, args).await;
let item = next_item(&mut stream).await.expect("should have terminal");
match item {
@ -2069,9 +2107,10 @@ mod tests {
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle);
let ctx = ToolCallContext::default();
let args = serde_json::json!(
{ "method" : "workspace.nonexistent", "params" : {} }
);
let args = serde_json::json!({
"method": "workspace.nonexistent",
"params": {}
});
let mut stream = handler.handle_call(ctx, args).await;
let item = next_item(&mut stream).await.expect("should have terminal");
match item {
@ -2107,9 +2146,7 @@ mod tests {
let mut stream = handler
.handle_call(
ctx,
serde_json::json!(
{ "method" : "workspace.get_session_summary", "params" : {} }
),
serde_json::json!({"method": "workspace.get_session_summary", "params": {}}),
)
.await;
let _ = next_item(&mut stream).await;
@ -2131,10 +2168,13 @@ mod tests {
let unknown_before = WORKSPACE_RPC_REQUESTS_TOTAL
.with_label_values(&[UNKNOWN_METHOD_LABEL, "error"])
.get();
let kind_before = WORKSPACE_RPC_ERRORS_TOTAL
.with_label_values(&[UNKNOWN_METHOD_LABEL, "hub_error"])
.get();
let mut stream = handler
.handle_call(
ToolCallContext::default(),
serde_json::json!({ "method" : BOGUS, "params" : {} }),
serde_json::json!({"method": BOGUS, "params": {}}),
)
.await;
let _ = next_item(&mut stream).await;
@ -2145,6 +2185,13 @@ mod tests {
> unknown_before,
"an unrecognized method must increment the collapsed unknown/error counter"
);
assert!(
WORKSPACE_RPC_ERRORS_TOTAL
.with_label_values(&[UNKNOWN_METHOD_LABEL, "hub_error"])
.get()
> kind_before,
"a failed dispatch must also record its error_kind on the errors counter"
);
let has_bogus_series = prometheus::gather()
.iter()
.filter(|mf| mf.name() == "grok_workspace_rpc_requests_total")
@ -2176,8 +2223,7 @@ mod tests {
.dispatch("workspace.git_commit", serde_json::json!({}), None)
.await;
assert!(
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg
.contains("missing field"))
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing field"))
);
}
#[tokio::test]
@ -2188,8 +2234,7 @@ mod tests {
.dispatch("workspace.git_checkout", serde_json::json!({}), None)
.await;
assert!(
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg
.contains("missing field"))
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing field"))
);
}
#[tokio::test]
@ -2200,8 +2245,7 @@ mod tests {
.dispatch("workspace.git_stage_content", serde_json::json!({}), None)
.await;
assert!(
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg
.contains("missing"))
matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing"))
);
}
#[tokio::test]
@ -2277,7 +2321,7 @@ mod tests {
hook_id: None,
event: HookEvent::Custom {
kind: turn_hook::BEFORE_TURN_KIND.to_string(),
payload: serde_json::json!({ "garbage" : true }),
payload: serde_json::json!({"garbage": true}),
},
trace_context: None,
};
@ -2385,9 +2429,9 @@ mod tests {
let handle = make_handle();
let root = handle.root_cwd().unwrap();
let handler = WorkspaceRpcHandler::new(handle);
let params = serde_json::json!(
{ "files" : [{ "path" : "test_file.txt", "content" : "hello world" }] }
);
let params = serde_json::json!({
"files": [{"path": "test_file.txt", "content": "hello world"}]
});
let result = handler
.dispatch("workspace.put_files", params, None)
.await
@ -2409,9 +2453,9 @@ mod tests {
async fn dispatch_put_files_rejects_path_traversal() {
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle);
let params = serde_json::json!(
{ "files" : [{ "path" : "../escape.txt", "content" : "evil" }] }
);
let params = serde_json::json!({
"files": [{"path": "../escape.txt", "content": "evil"}]
});
let result = handler
.dispatch("workspace.put_files", params, None)
.await
@ -2451,9 +2495,9 @@ mod tests {
async fn dispatch_put_files_rejects_absolute_outside_root() {
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle);
let params = serde_json::json!(
{ "files" : [{ "path" : "/etc/passwd", "content" : "evil" }] }
);
let params = serde_json::json!({
"files": [{"path": "/etc/passwd", "content": "evil"}]
});
let result = handler
.dispatch("workspace.put_files", params, None)
.await
@ -2480,10 +2524,9 @@ mod tests {
let root = handle.root_cwd().unwrap();
let handler = WorkspaceRpcHandler::new(handle);
let abs = root.join("sub/abs.txt");
let params = serde_json::json!(
{ "files" : [{ "path" : abs.to_str().expect("utf-8 path"), "content" :
"hello" }] }
);
let params = serde_json::json!({
"files": [{"path": abs.to_str().expect("utf-8 path"), "content": "hello"}]
});
let result = handler
.dispatch("workspace.put_files", params, None)
.await
@ -2509,9 +2552,9 @@ mod tests {
let outside = tempfile::tempdir().expect("create outside dir");
std::os::unix::fs::symlink(outside.path(), root.join("escape_link"))
.expect("create symlink");
let params = serde_json::json!(
{ "files" : [{ "path" : "escape_link/evil.txt", "content" : "pwned" }] }
);
let params = serde_json::json!({
"files": [{"path": "escape_link/evil.txt", "content": "pwned"}]
});
let result = handler
.dispatch("workspace.put_files", params, None)
.await
@ -2538,10 +2581,12 @@ mod tests {
let handle = make_handle();
let root = handle.root_cwd().unwrap();
let handler = WorkspaceRpcHandler::new(handle);
let params = serde_json::json!(
{ "files" : [{ "path" : "good.txt", "content" : "valid content" }, { "path" :
"../bad.txt", "content" : "should fail" },] }
);
let params = serde_json::json!({
"files": [
{"path": "good.txt", "content": "valid content"},
{"path": "../bad.txt", "content": "should fail"},
]
});
let result = handler
.dispatch("workspace.put_files", params, None)
.await
@ -2569,7 +2614,9 @@ mod tests {
let handler = WorkspaceRpcHandler::new(handle);
let content = "read me back";
std::fs::write(root.join("readable.txt"), content).unwrap();
let params = serde_json::json!({ "files" : [{ "path" : "readable.txt" }] });
let params = serde_json::json!({
"files": [{"path": "readable.txt"}]
});
let result = handler
.dispatch("workspace.get_files", params, None)
.await
@ -2600,9 +2647,9 @@ mod tests {
async fn dispatch_get_files_nonexistent_returns_not_exists() {
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle);
let params = serde_json::json!(
{ "files" : [{ "path" : "does_not_exist.txt" }] }
);
let params = serde_json::json!({
"files": [{"path": "does_not_exist.txt"}]
});
let result = handler
.dispatch("workspace.get_files", params, None)
.await
@ -2624,7 +2671,9 @@ mod tests {
let root = handle.root_cwd().unwrap();
let handler = WorkspaceRpcHandler::new(handle);
std::fs::create_dir_all(root.join("a_directory")).unwrap();
let params = serde_json::json!({ "files" : [{ "path" : "a_directory" }] });
let params = serde_json::json!({
"files": [{"path": "a_directory"}]
});
let result = handler
.dispatch("workspace.get_files", params, None)
.await
@ -2646,7 +2695,9 @@ mod tests {
let handler = WorkspaceRpcHandler::new(handle);
let binary_content: &[u8] = b"\xff\xfe\x00\x01";
std::fs::write(root.join("binary.bin"), binary_content).unwrap();
let params = serde_json::json!({ "files" : [{ "path" : "binary.bin" }] });
let params = serde_json::json!({
"files": [{"path": "binary.bin"}]
});
let result = handler
.dispatch("workspace.get_files", params, None)
.await
@ -2687,9 +2738,9 @@ mod tests {
let content = "cacheable content";
std::fs::write(root.join("cached.txt"), content).unwrap();
let expected_hash = test_sha256(content.as_bytes());
let params = serde_json::json!(
{ "files" : [{ "path" : "cached.txt", "if_none_match" : expected_hash }] }
);
let params = serde_json::json!({
"files": [{"path": "cached.txt", "if_none_match": expected_hash}]
});
let result = handler
.dispatch("workspace.get_files", params, None)
.await
@ -2716,10 +2767,9 @@ mod tests {
let handler = WorkspaceRpcHandler::new(handle);
let content = "fresh content";
std::fs::write(root.join("stale.txt"), content).unwrap();
let params = serde_json::json!(
{ "files" : [{ "path" : "stale.txt", "if_none_match" :
"0000000000000000000000000000000000000000000000000000000000000000" }] }
);
let params = serde_json::json!({
"files": [{"path": "stale.txt", "if_none_match": "0000000000000000000000000000000000000000000000000000000000000000"}]
});
let result = handler
.dispatch("workspace.get_files", params, None)
.await
@ -2745,9 +2795,9 @@ mod tests {
let handle = make_handle();
let handler = WorkspaceRpcHandler::new(handle);
let content = "round trip content";
let put_params = serde_json::json!(
{ "files" : [{ "path" : "round_trip.txt", "content" : content }] }
);
let put_params = serde_json::json!({
"files": [{"path": "round_trip.txt", "content": content}]
});
let put_result = handler
.dispatch("workspace.put_files", put_params, None)
.await
@ -2755,9 +2805,9 @@ mod tests {
let put_res: PutFilesRes = serde_json::from_value(put_result).unwrap();
assert!(put_res.results[0].ok);
let put_hash = put_res.results[0].hash.clone().unwrap();
let get_params = serde_json::json!(
{ "files" : [{ "path" : "round_trip.txt" }] }
);
let get_params = serde_json::json!({
"files": [{"path": "round_trip.txt"}]
});
let get_result = handler
.dispatch("workspace.get_files", get_params, None)
.await
@ -2780,10 +2830,9 @@ mod tests {
let handle = make_handle();
let root = handle.root_cwd().unwrap();
let handler = WorkspaceRpcHandler::new(handle);
let params1 = serde_json::json!(
{ "files" : [{ "path" : "chunked.txt", "content" : "hello", "append" : false
}] }
);
let params1 = serde_json::json!({
"files": [{"path": "chunked.txt", "content": "hello", "append": false}]
});
let res1 = handler
.dispatch("workspace.put_files", params1, None)
.await
@ -2796,10 +2845,9 @@ mod tests {
test_sha256(b"hello"),
"hash should be of the appended chunk, not full file"
);
let params2 = serde_json::json!(
{ "files" : [{ "path" : "chunked.txt", "content" : " world", "append" : true
}] }
);
let params2 = serde_json::json!({
"files": [{"path": "chunked.txt", "content": " world", "append": true}]
});
let res2 = handler
.dispatch("workspace.put_files", params2, None)
.await
@ -2822,9 +2870,9 @@ mod tests {
let handler = WorkspaceRpcHandler::new(handle);
let content = "0123456789";
std::fs::write(root.join("range.txt"), content).unwrap();
let params = serde_json::json!(
{ "files" : [{ "path" : "range.txt", "offset" : 3, "length" : 4 }] }
);
let params = serde_json::json!({
"files": [{"path": "range.txt", "offset": 3, "length": 4}]
});
let result = handler
.dispatch("workspace.get_files", params, None)
.await
@ -2858,10 +2906,14 @@ mod tests {
let content = "abcdefghij";
std::fs::write(root.join("range_cache.txt"), content).unwrap();
let full_hash = test_sha256(content.as_bytes());
let params = serde_json::json!(
{ "files" : [{ "path" : "range_cache.txt", "offset" : 2, "length" : 3,
"if_none_match" : full_hash, }] }
);
let params = serde_json::json!({
"files": [{
"path": "range_cache.txt",
"offset": 2,
"length": 3,
"if_none_match": full_hash,
}]
});
let result = handler
.dispatch("workspace.get_files", params, None)
.await