708 lines
26 KiB
Rust
708 lines
26 KiB
Rust
//! Generic immutable archive installation and Computer-local stdio MCP calls.
|
|
use axum::{
|
|
Json,
|
|
extract::{Path, State},
|
|
http::StatusCode,
|
|
};
|
|
use lazyboy_control::{
|
|
AdapterContext, CommandRequest, ToolManifest, resolve_bot_workspace_path, validate_manifest,
|
|
};
|
|
use serde::Deserialize;
|
|
use serde_json::{Value, json};
|
|
use uuid::Uuid;
|
|
|
|
use crate::{
|
|
computer,
|
|
db::{Actor, parse_mode},
|
|
state::AppState,
|
|
tools::ToolCtx,
|
|
};
|
|
type ApiError = (StatusCode, Json<Value>);
|
|
|
|
fn failure(message: impl ToString) -> ApiError {
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"message":message.to_string()})),
|
|
)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct InstallRequest {
|
|
manifest: ToolManifest,
|
|
#[serde(rename = "artifactPath")]
|
|
artifact_path: String,
|
|
#[serde(default)]
|
|
reauthorize: bool,
|
|
}
|
|
|
|
pub async fn install(
|
|
State(state): State<AppState>,
|
|
actor: Actor,
|
|
Path(bot_id): Path<String>,
|
|
Json(mut body): Json<InstallRequest>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
if !crate::tool_install::tool_install_enabled() {
|
|
return Err(failure("tool install disabled"));
|
|
}
|
|
body.manifest.sha256 = body.manifest.sha256.to_ascii_lowercase();
|
|
validate_manifest(&body.manifest)
|
|
.map_err(|error| failure(format!("invalid manifest: {error:?}")))?;
|
|
state
|
|
.db
|
|
.get_bot(&actor, &bot_id)
|
|
.await
|
|
.map_err(failure)?
|
|
.ok_or_else(|| failure("bot not found"))?;
|
|
computer::boot_for(&state, &actor, &bot_id, false)
|
|
.await
|
|
.map_err(failure)?;
|
|
let bot = state
|
|
.db
|
|
.get_bot(&actor, &bot_id)
|
|
.await
|
|
.map_err(failure)?
|
|
.ok_or_else(|| failure("bot not found"))?;
|
|
let computer_id = bot.computer_id.ok_or_else(|| failure("Computer missing"))?;
|
|
let computer = state
|
|
.db
|
|
.get_computer(&computer_id)
|
|
.await
|
|
.map_err(failure)?
|
|
.ok_or_else(|| failure("Computer missing"))?;
|
|
let reference =
|
|
computer::computer_ref(&computer).ok_or_else(|| failure("Computer unavailable"))?;
|
|
let artifact =
|
|
resolve_bot_workspace_path(parse_mode(&computer.scope), &bot_id, &body.artifact_path)
|
|
.map_err(failure)?;
|
|
let manifest = serde_json::to_value(&body.manifest).map_err(failure)?;
|
|
let mut transaction = state.pool().begin().await.map_err(failure)?;
|
|
sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended('package-install:' || $1,0))")
|
|
.bind(format!("{computer_id}:{}", body.manifest.id))
|
|
.execute(&mut *transaction)
|
|
.await
|
|
.map_err(failure)?;
|
|
let pending: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM package_transitions WHERE bot_id=$1 AND package_id=$2 AND status IN ('pending','cancelling'))")
|
|
.bind(&bot_id).bind(&body.manifest.id).fetch_one(&mut *transaction).await.map_err(failure)?;
|
|
if pending {
|
|
return Err(failure(
|
|
"version transition pending; resume or revoke it before installing",
|
|
));
|
|
}
|
|
let package_id: String = sqlx::query_scalar(
|
|
"INSERT INTO tool_packages(id,computer_id,package_id,version,sha256,status,manifest)
|
|
VALUES ($1,$2,$3,$4,$5,'installing',$6)
|
|
ON CONFLICT(computer_id,package_id,version) DO UPDATE SET status=tool_packages.status
|
|
RETURNING id",
|
|
)
|
|
.bind(Uuid::new_v4().to_string())
|
|
.bind(&computer_id)
|
|
.bind(&body.manifest.id)
|
|
.bind(&body.manifest.version)
|
|
.bind(&body.manifest.sha256)
|
|
.bind(&manifest)
|
|
.fetch_one(&mut *transaction)
|
|
.await
|
|
.map_err(failure)?;
|
|
let stored: (String, Option<Value>) =
|
|
sqlx::query_as("SELECT sha256,manifest FROM tool_packages WHERE id=$1")
|
|
.bind(&package_id)
|
|
.fetch_one(&mut *transaction)
|
|
.await
|
|
.map_err(failure)?;
|
|
if stored.0 != body.manifest.sha256 || stored.1.as_ref() != Some(&manifest) {
|
|
return Err(failure("immutable package version conflict"));
|
|
}
|
|
// Lock live rows too: a concurrent revoke must not turn a normal retry
|
|
// into an implicitly authorized new binding between this check and INSERT.
|
|
let previous: Vec<(String, String)> = sqlx::query_as("SELECT id,status FROM tool_bindings WHERE package_row_id=$1 AND bot_id=$2 ORDER BY created_at FOR UPDATE")
|
|
.bind(&package_id).bind(&bot_id).fetch_all(&mut *transaction).await.map_err(failure)?;
|
|
let has_live = previous
|
|
.iter()
|
|
.any(|(_, status)| matches!(status.as_str(), "installing" | "ready"));
|
|
if !has_live && !previous.is_empty() {
|
|
if !body.reauthorize {
|
|
return Err(failure(
|
|
"binding revoked; explicit new authorization required",
|
|
));
|
|
}
|
|
for (old_id, _) in &previous {
|
|
revoke_runtime(&state, &actor, &bot_id, old_id)
|
|
.await
|
|
.map_err(failure)?;
|
|
}
|
|
}
|
|
let binding_id: String = sqlx::query_scalar(
|
|
"INSERT INTO tool_bindings(id,package_row_id,bot_id,status) VALUES ($1,$2,$3,'installing')
|
|
ON CONFLICT(package_row_id,bot_id) WHERE status IN ('installing','ready','switching','retiring') DO UPDATE SET status=tool_bindings.status RETURNING id",
|
|
)
|
|
.bind(Uuid::new_v4().to_string())
|
|
.bind(&package_id)
|
|
.bind(&bot_id)
|
|
.fetch_one(&mut *transaction)
|
|
.await
|
|
.map_err(failure)?;
|
|
let status: String = sqlx::query_scalar("SELECT status FROM tool_bindings WHERE id=$1")
|
|
.bind(&binding_id)
|
|
.fetch_one(&mut *transaction)
|
|
.await
|
|
.map_err(failure)?;
|
|
if matches!(status.as_str(), "revoked" | "removed") {
|
|
return Err(failure(
|
|
"binding revoked; explicit new authorization required",
|
|
));
|
|
}
|
|
transaction.commit().await.map_err(failure)?;
|
|
let adapter = AdapterContext {
|
|
bot_id: Some(bot_id.clone()),
|
|
computer_generation: Some(computer.generation),
|
|
operation_id: format!("install:{binding_id}"),
|
|
space_id: actor.space_id.clone(),
|
|
user_id: actor.user_id.clone(),
|
|
..Default::default()
|
|
};
|
|
let installed = state.sandbox.execute(&reference, CommandRequest {
|
|
job_op: Some("package_install".into()), stdin: Some(json!({"manifest":manifest,"artifact_relative":artifact,"binding_id":binding_id}).to_string()),
|
|
..Default::default()
|
|
}, &adapter).await.map_err(|_| failure("Computer installation incomplete; the recorded intent is retained"))?;
|
|
if installed.code != 0 {
|
|
return Err(failure("Computer installation incomplete"));
|
|
}
|
|
let discovery = state
|
|
.sandbox
|
|
.execute(
|
|
&reference,
|
|
CommandRequest {
|
|
job_op: Some("package_call".into()),
|
|
stdin: Some(json!({"binding_id":binding_id,"method":"tools/list"}).to_string()),
|
|
..Default::default()
|
|
},
|
|
&adapter,
|
|
)
|
|
.await
|
|
.map_err(|_| failure("MCP discovery failed; binding is not ready"))?;
|
|
if discovery.code != 0 {
|
|
return Err(failure("MCP discovery failed"));
|
|
}
|
|
let tools: Value = serde_json::from_str(&discovery.stdout)
|
|
.map_err(|_| failure("invalid MCP discovery response"))?;
|
|
if !tools.is_array() {
|
|
return Err(failure("invalid MCP tool list"));
|
|
}
|
|
let mut transaction = state.pool().begin().await.map_err(failure)?;
|
|
sqlx::query("UPDATE tool_packages SET status='installed' WHERE id=$1")
|
|
.bind(&package_id)
|
|
.execute(&mut *transaction)
|
|
.await
|
|
.map_err(failure)?;
|
|
let ready = sqlx::query("UPDATE tool_bindings b SET status='ready' FROM bots bot WHERE b.id=$1 AND bot.id=b.bot_id AND bot.computer_id=$2 AND b.status IN ('installing','ready')")
|
|
.bind(&binding_id).bind(&computer_id).execute(&mut *transaction).await.map_err(failure)?;
|
|
if ready.rows_affected() != 1 {
|
|
return Err(failure("assignment or binding changed during installation"));
|
|
}
|
|
transaction.commit().await.map_err(failure)?;
|
|
Ok(Json(
|
|
json!({"bindingId":binding_id,"packageId":body.manifest.id,"version":body.manifest.version,"tools":tools,"executionLocation":"assigned_computer"}),
|
|
))
|
|
}
|
|
|
|
pub async fn any_bound(
|
|
pool: &sqlx::PgPool,
|
|
actor: &Actor,
|
|
bot_id: &str,
|
|
) -> Result<bool, sqlx::Error> {
|
|
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM tool_bindings b JOIN tool_packages p ON p.id=b.package_row_id JOIN bots bot ON bot.id=b.bot_id WHERE bot.id=$1 AND bot.space_id=$2 AND bot.user_id=$3 AND p.computer_id=bot.computer_id AND b.status='ready' AND p.status='installed')")
|
|
.bind(bot_id).bind(&actor.space_id).bind(&actor.user_id).fetch_one(pool).await
|
|
}
|
|
|
|
pub struct CallError {
|
|
pub code: &'static str,
|
|
pub message: &'static str,
|
|
}
|
|
|
|
fn rejected(message: &'static str) -> CallError {
|
|
CallError {
|
|
code: "MCP_REJECTED",
|
|
message,
|
|
}
|
|
}
|
|
|
|
fn unknown(message: &'static str) -> CallError {
|
|
CallError {
|
|
code: "UNKNOWN_EFFECT",
|
|
message,
|
|
}
|
|
}
|
|
|
|
pub async fn call(ctx: &ToolCtx, args: &Value) -> Result<Value, CallError> {
|
|
call_on_computer(
|
|
&ctx.pool,
|
|
&ctx.actor,
|
|
&ctx.bot_id,
|
|
ctx.sandbox.as_ref(),
|
|
&ctx.computer_ref(),
|
|
&ctx.adapter(),
|
|
args,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn call_on_computer(
|
|
pool: &sqlx::PgPool,
|
|
actor: &Actor,
|
|
bot_id: &str,
|
|
sandbox: &dyn lazyboy_control::SandboxProvider,
|
|
computer_ref: &lazyboy_control::ComputerRef,
|
|
adapter: &AdapterContext,
|
|
args: &Value,
|
|
) -> Result<Value, CallError> {
|
|
let rows: Vec<(String, String, String, String)> = sqlx::query_as(
|
|
"SELECT b.id,p.package_id,p.version,p.sha256 FROM tool_bindings b JOIN tool_packages p ON p.id=b.package_row_id
|
|
JOIN bots bot ON bot.id=b.bot_id WHERE bot.id=$1 AND bot.space_id=$2 AND bot.user_id=$3 AND p.computer_id=bot.computer_id
|
|
AND b.status='ready' AND p.status='installed' AND p.manifest IS NOT NULL")
|
|
.bind(bot_id).bind(&actor.space_id).bind(&actor.user_id).fetch_all(pool).await.map_err(|_| rejected("package binding lookup failed"))?;
|
|
let action = args["action"].as_str().unwrap_or("call");
|
|
if action == "list" {
|
|
return Ok(
|
|
json!({"bindings":rows.into_iter().map(|(id,package,version,_)| json!({"bindingId":id,"packageId":package,"version":version})).collect::<Vec<_>>()}),
|
|
);
|
|
}
|
|
if !matches!(action, "discover" | "call") {
|
|
return Err(rejected("unsupported installed MCP action"));
|
|
}
|
|
let binding = args["bindingId"]
|
|
.as_str()
|
|
.ok_or_else(|| rejected("bindingId required; list installed packages first"))?;
|
|
let bound = rows
|
|
.iter()
|
|
.find(|row| row.0 == binding)
|
|
.ok_or_else(|| rejected("binding is not authorized on this Computer"))?;
|
|
if action == "call"
|
|
&& (args["tool"]
|
|
.as_str()
|
|
.is_none_or(|name| name.trim().is_empty())
|
|
|| args
|
|
.get("arguments")
|
|
.is_some_and(|value| !value.is_object()))
|
|
{
|
|
return Err(rejected("call requires a tool name and object arguments"));
|
|
}
|
|
let payload = json!({"binding_id":binding,"expected_sha256":bound.3,"expected_package_id":bound.1,"expected_version":bound.2,"method":if action == "discover" { "tools/list" } else { "tools/call" },
|
|
"name":args["tool"],"arguments":args.get("arguments").cloned().unwrap_or(json!({}))});
|
|
let result = sandbox
|
|
.execute(
|
|
computer_ref,
|
|
CommandRequest {
|
|
job_op: Some("package_call".into()),
|
|
stdin: Some(payload.to_string()),
|
|
..Default::default()
|
|
},
|
|
adapter,
|
|
)
|
|
.await
|
|
.map_err(|_| unknown("installed MCP failed; effects may be unknown"))?;
|
|
if result.code != 0 {
|
|
return Err(unknown("installed MCP failed; effects may be unknown"));
|
|
}
|
|
let value: Value =
|
|
serde_json::from_str(&result.stdout).map_err(|_| unknown("invalid MCP response"))?;
|
|
if value["isError"].as_bool() == Some(true) {
|
|
return Err(unknown("MCP reported an error; effects may be unknown"));
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
/// Re-read schemas from the authorized binding on its current Computer.
|
|
pub async fn discover(
|
|
State(state): State<AppState>,
|
|
actor: Actor,
|
|
Path((bot_id, binding_id)): Path<(String, String)>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
let bot = state
|
|
.db
|
|
.get_bot(&actor, &bot_id)
|
|
.await
|
|
.map_err(failure)?
|
|
.ok_or_else(|| {
|
|
(
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"message":"bot not found"})),
|
|
)
|
|
})?;
|
|
let computer = state
|
|
.db
|
|
.get_computer(bot.computer_id.as_deref().unwrap_or(""))
|
|
.await
|
|
.map_err(failure)?
|
|
.ok_or_else(|| failure("Computer missing"))?;
|
|
if computer.state != "running" {
|
|
return Err(failure("Computer is not running"));
|
|
}
|
|
let computer_ref =
|
|
computer::computer_ref(&computer).ok_or_else(|| failure("Computer is not running"))?;
|
|
let mut adapter = computer::adapter_context(&actor, &bot_id, "tool-discovery");
|
|
adapter.computer_generation = Some(computer.generation);
|
|
let value = call_on_computer(
|
|
state.pool(),
|
|
&actor,
|
|
&bot_id,
|
|
state.sandbox.as_ref(),
|
|
&computer_ref,
|
|
&adapter,
|
|
&json!({"action":"discover","bindingId":binding_id}),
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
(
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"message":error.message,"code":error.code})),
|
|
)
|
|
})?;
|
|
Ok(Json(value))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
|
pub struct InvokeRequest {
|
|
operation_id: Uuid,
|
|
tool: String,
|
|
arguments: serde_json::Map<String, Value>,
|
|
}
|
|
|
|
pub async fn invoke(
|
|
State(state): State<AppState>,
|
|
actor: Actor,
|
|
Path((bot_id, binding_id)): Path<(String, String)>,
|
|
Json(body): Json<InvokeRequest>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
if body.tool.trim().is_empty() {
|
|
return Err(failure("tool name required"));
|
|
}
|
|
let bot = state
|
|
.db
|
|
.get_bot(&actor, &bot_id)
|
|
.await
|
|
.map_err(failure)?
|
|
.ok_or_else(|| {
|
|
(
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"message":"bot not found"})),
|
|
)
|
|
})?;
|
|
let computer = state
|
|
.db
|
|
.get_computer(bot.computer_id.as_deref().unwrap_or(""))
|
|
.await
|
|
.map_err(failure)?
|
|
.ok_or_else(|| failure("Computer missing"))?;
|
|
if computer.state != "running" {
|
|
return Err(failure("Computer is not running"));
|
|
}
|
|
let computer_ref =
|
|
computer::computer_ref(&computer).ok_or_else(|| failure("Computer is not running"))?;
|
|
let ctx = ToolCtx::for_tool_manager(
|
|
&state,
|
|
&actor,
|
|
&bot_id,
|
|
computer_ref,
|
|
parse_mode(&computer.scope),
|
|
);
|
|
ctx.context.lock().unwrap().computer_generation = Some(computer.generation);
|
|
let args = json!({"action":"call","bindingId":binding_id,"tool":body.tool,"arguments":body.arguments,"operationId":body.operation_id});
|
|
let outcome = crate::tools::dispatch(&ctx, "computer_mcp", &args).await;
|
|
if let Some(code) = outcome.error_code {
|
|
return Err((
|
|
StatusCode::CONFLICT,
|
|
Json(json!({"message":outcome.text,"code":code,"operationId":body.operation_id})),
|
|
));
|
|
}
|
|
let result: Value = serde_json::from_str(&outcome.text).map_err(failure)?;
|
|
Ok(Json(
|
|
json!({"result":result,"operationId":body.operation_id}),
|
|
))
|
|
}
|
|
|
|
pub async fn revoke_runtime(
|
|
state: &AppState,
|
|
actor: &Actor,
|
|
bot_id: &str,
|
|
binding_id: &str,
|
|
) -> Result<(), String> {
|
|
let computer_id: Option<String> = sqlx::query_scalar("SELECT p.computer_id FROM tool_bindings b JOIN tool_packages p ON p.id=b.package_row_id JOIN bots bot ON bot.id=b.bot_id WHERE b.id=$1 AND b.bot_id=$2 AND bot.space_id=$3 AND bot.user_id=$4 AND p.manifest IS NOT NULL")
|
|
.bind(binding_id).bind(bot_id).bind(&actor.space_id).bind(&actor.user_id).fetch_optional(state.pool()).await.map_err(|_| "binding lookup failed")?;
|
|
let Some(id) = computer_id else { return Ok(()) };
|
|
let computer = state
|
|
.db
|
|
.get_computer(&id)
|
|
.await
|
|
.map_err(|_| "Computer lookup failed")?
|
|
.ok_or("Computer unavailable")?;
|
|
if computer.state != "running" {
|
|
return Err(
|
|
"binding is centrally revoked; local drain awaits Computer availability".into(),
|
|
);
|
|
}
|
|
let reference = computer::computer_ref(&computer).ok_or("Computer unavailable")?;
|
|
let adapter = AdapterContext {
|
|
bot_id: Some(bot_id.into()),
|
|
computer_generation: Some(computer.generation),
|
|
operation_id: format!("revoke:{binding_id}"),
|
|
space_id: actor.space_id.clone(),
|
|
user_id: actor.user_id.clone(),
|
|
..Default::default()
|
|
};
|
|
let result = state
|
|
.sandbox
|
|
.execute(
|
|
&reference,
|
|
CommandRequest {
|
|
job_op: Some("package_revoke".into()),
|
|
stdin: Some(json!({"binding_id":binding_id}).to_string()),
|
|
..Default::default()
|
|
},
|
|
&adapter,
|
|
)
|
|
.await
|
|
.map_err(|_| "binding is revoked; runtime drain not confirmed")?;
|
|
if result.code != 0 {
|
|
return Err("binding is revoked; runtime drain not confirmed".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[sqlx::test(migrations = "../../migrations")]
|
|
async fn installed_visibility_follows_owner_binding_and_current_computer(pool: sqlx::PgPool) {
|
|
for sql in [
|
|
"INSERT INTO users(id,name) VALUES ('u','owner'),('v','other')",
|
|
"INSERT INTO spaces(id,user_id,name) VALUES ('s','u','test'),('other','v','other')",
|
|
"INSERT INTO computers(id,space_id,user_id,scope,scope_key,home_key) VALUES ('c','s','u','team','team:s','home'),('d','s','u','dedicated','bot:d','private')",
|
|
"INSERT INTO bots(id,space_id,user_id,name,computer_id) VALUES ('a','s','u','A','c'),('b','s','u','B','c')",
|
|
"INSERT INTO tool_packages(id,computer_id,package_id,version,sha256,status,manifest) VALUES ('p','c','fixture','1.0.0','digest','installed','{}')",
|
|
"INSERT INTO tool_bindings(id,package_row_id,bot_id,status) VALUES ('binding','p','a','ready')",
|
|
] {
|
|
sqlx::query(sql).execute(&pool).await.unwrap();
|
|
}
|
|
let owner = Actor {
|
|
user_id: "u".into(),
|
|
space_id: "s".into(),
|
|
};
|
|
let other = Actor {
|
|
user_id: "v".into(),
|
|
space_id: "other".into(),
|
|
};
|
|
let state = AppState {
|
|
db: crate::db::Db { pool: pool.clone() },
|
|
sandbox: std::sync::Arc::new(lazyboy_sandbox::FakeSandbox::new()),
|
|
data_dir: String::new(),
|
|
auth: crate::auth::AuthConfig::from_env(),
|
|
memory: crate::memory::MemoryService::from_env(),
|
|
mcp: crate::mcp::McpHub::new(),
|
|
calls: crate::state::CallRegistry::default(),
|
|
wakes: crate::state::WakeBus::default(),
|
|
};
|
|
sqlx::query("UPDATE computers SET state='running',provider_ref='fixture' WHERE id='c'")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query("UPDATE computers SET state='stopped' WHERE id='c'")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
let stopped = discover(
|
|
State(state.clone()),
|
|
owner.clone(),
|
|
Path(("a".into(), "binding".into())),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(stopped.1.0["message"], "Computer is not running");
|
|
sqlx::query("UPDATE computers SET state='running' WHERE id='c'")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
let operation_id = Uuid::new_v4();
|
|
let request = || InvokeRequest {
|
|
operation_id,
|
|
tool: "fixture_tool".into(),
|
|
arguments: serde_json::Map::new(),
|
|
};
|
|
let unauthorized = invoke(
|
|
State(state.clone()),
|
|
other.clone(),
|
|
Path(("a".into(), "binding".into())),
|
|
Json(request()),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(unauthorized.0, StatusCode::NOT_FOUND);
|
|
let rejected = invoke(
|
|
State(state.clone()),
|
|
owner.clone(),
|
|
Path(("b".into(), "binding".into())),
|
|
Json(request()),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(rejected.1.0["code"], "MCP_REJECTED");
|
|
// The fake rejects MCP dispatch: treat transport failure as unknown, never replay it.
|
|
let unknown_result = invoke(
|
|
State(state.clone()),
|
|
owner.clone(),
|
|
Path(("a".into(), "binding".into())),
|
|
Json(request()),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(unknown_result.1.0["code"], "UNKNOWN_EFFECT");
|
|
let stored_run: Option<String> =
|
|
sqlx::query_scalar("SELECT run_id FROM computer_operations WHERE id=$1")
|
|
.bind(format!("a:{operation_id}"))
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
stored_run.is_none(),
|
|
"management calls must not invent a run"
|
|
);
|
|
let mut changed = request();
|
|
changed.tool = "another_tool".into();
|
|
let mismatch = invoke(
|
|
State(state.clone()),
|
|
owner.clone(),
|
|
Path(("a".into(), "binding".into())),
|
|
Json(changed),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(mismatch.1.0["code"], "PAYLOAD_MISMATCH");
|
|
assert!(any_bound(&pool, &owner, "a").await.unwrap());
|
|
assert!(!any_bound(&pool, &other, "a").await.unwrap());
|
|
assert!(!any_bound(&pool, &owner, "b").await.unwrap());
|
|
sqlx::query("UPDATE bots SET computer_id='d' WHERE id='a'")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert!(!any_bound(&pool, &owner, "a").await.unwrap());
|
|
sqlx::query("UPDATE bots SET computer_id='c' WHERE id='a'")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query("UPDATE tool_bindings SET status='revoked' WHERE id='binding'")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert!(!any_bound(&pool, &owner, "a").await.unwrap());
|
|
let replay = invoke(
|
|
State(state.clone()),
|
|
owner.clone(),
|
|
Path(("a".into(), "binding".into())),
|
|
Json(request()),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
replay.1.0["code"], "UNKNOWN_EFFECT",
|
|
"replay must not dispatch to the now-revoked binding"
|
|
);
|
|
let discovery = discover(
|
|
State(state.clone()),
|
|
owner.clone(),
|
|
Path(("a".into(), "binding".into())),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(discovery.1.0["code"], "MCP_REJECTED");
|
|
sqlx::query("UPDATE operation_outbox SET created_at=now()-interval '5 seconds'")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
crate::operations::flush_outbox(&pool).await;
|
|
let pending: i64 =
|
|
sqlx::query_scalar("SELECT count(*) FROM operation_outbox WHERE NOT delivered")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
pending, 0,
|
|
"management events must not retry an invalid run_activity foreign key forever"
|
|
);
|
|
let manifest = ToolManifest {
|
|
id: "fixture.reauthorized".into(),
|
|
version: "1.0.0".into(),
|
|
sha256: "0".repeat(64),
|
|
entrypoint: vec!["python3".into(), "./server.py".into()],
|
|
share_immutable_package: true,
|
|
};
|
|
sqlx::query("UPDATE tool_packages SET package_id=$1,sha256=$2,manifest=$3 WHERE id='p'")
|
|
.bind(&manifest.id)
|
|
.bind(&manifest.sha256)
|
|
.bind(serde_json::to_value(&manifest).unwrap())
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
let install_request = |reauthorize| InstallRequest {
|
|
manifest: serde_json::from_value(serde_json::to_value(&manifest).unwrap()).unwrap(),
|
|
artifact_path: "server.tar.gz".into(),
|
|
reauthorize,
|
|
};
|
|
let denied = install(
|
|
State(state.clone()),
|
|
owner.clone(),
|
|
Path("a".into()),
|
|
Json(install_request(false)),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(
|
|
denied.1.0["message"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("explicit new authorization")
|
|
);
|
|
// Fake rejects package installation dispatch: intent must survive as installing.
|
|
let _ = install(
|
|
State(state.clone()),
|
|
owner.clone(),
|
|
Path("a".into()),
|
|
Json(install_request(true)),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
let bindings: Vec<(String, String)> = sqlx::query_as(
|
|
"SELECT id,status FROM tool_bindings WHERE package_row_id='p' AND bot_id='a'",
|
|
)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(bindings.len(), 2);
|
|
assert!(bindings.contains(&("binding".into(), "revoked".into())));
|
|
assert!(
|
|
bindings
|
|
.iter()
|
|
.any(|(id, status)| id != "binding" && status == "installing")
|
|
);
|
|
let _ = install(
|
|
State(state),
|
|
owner,
|
|
Path("a".into()),
|
|
Json(install_request(true)),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
let count: i64 = sqlx::query_scalar(
|
|
"SELECT count(*) FROM tool_bindings WHERE package_row_id='p' AND bot_id='a'",
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
count, 2,
|
|
"retry reuses the new installing intent, never the retired id"
|
|
);
|
|
}
|
|
}
|