auth: enforce workspace worker credentials over ticket REST

This commit is contained in:
2026-08-01 17:23:59 +09:00
parent 3412f1c0ed
commit 81e631e640
11 changed files with 1853 additions and 283 deletions
+15 -1
View File
@@ -26,7 +26,10 @@ use crate::shutdown_after_idle::{
use crate::spawn::comm_tools::{read_worker_output_tool, send_to_worker_tool, stop_worker_tool};
use crate::spawn::registry::SpawnedWorkerRegistry;
use crate::spawn::tool::spawn_worker_tool;
use crate::worker::{SystemItemCommitter, Worker, WorkerError, WorkerRunResult};
use crate::worker::{
SystemItemCommitter, Worker, WorkerError, WorkerRunResult, WorkspaceClient,
WorkspaceClientError,
};
use protocol::{
AlertLevel, AlertSource, ErrorCode, Event, Method, RewindTargetId, RunResult, Segment,
TurnResult, WorkerStatus,
@@ -40,6 +43,7 @@ use protocol::{
pub struct WorkerHandle {
method_tx: mpsc::Sender<Method>,
event_tx: broadcast::Sender<Event>,
workspace_client: Arc<dyn WorkspaceClient>,
pub shared_state: Arc<WorkerSharedState>,
pub runtime_dir: Arc<RuntimeDir>,
pub alerter: Alerter,
@@ -113,6 +117,14 @@ impl WorkerHandle {
pub fn alert(&self, level: AlertLevel, source: AlertSource, message: String) {
self.alerter.alert(level, source, message);
}
/// Replace the Runtime-issued Workspace access token used by this live Worker.
pub fn replace_workspace_access_token(
&self,
access_token: String,
) -> Result<(), WorkspaceClientError> {
self.workspace_client.replace_access_token(access_token)
}
}
async fn set_controller_status(
@@ -234,6 +246,7 @@ impl WorkerController {
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let (method_tx, method_rx) = mpsc::channel::<Method>(32);
let (event_tx, _) = broadcast::channel::<Event>(256);
let workspace_client = worker.workspace_client_handle();
let alerter = Alerter::new(event_tx.clone());
let in_flight = InFlightEvents::new(event_tx.clone());
worker.attach_in_flight_events(in_flight.clone());
@@ -352,6 +365,7 @@ impl WorkerController {
let handle = WorkerHandle {
method_tx,
event_tx: event_tx.clone(),
workspace_client,
shared_state: shared_state.clone(),
runtime_dir: runtime_dir.clone(),
alerter: alerter.clone(),
+336 -50
View File
@@ -12,10 +12,10 @@ use std::{
use ticket::{
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
Ticket, TicketBackend, TicketBackendHttpResponse, TicketBackendOperation,
TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketIdOrSlug,
TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation, TicketRelationKind,
TicketRelationView, TicketReview, TicketStateChange, TicketSummary,
Ticket, TicketBackend, TicketBackendOperation, TicketBackendOperationResult,
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketReview,
TicketStateChange, TicketSummary,
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
};
@@ -387,57 +387,310 @@ impl WorkspaceHttpTicketBackend {
Self { client }
}
fn endpoint(&self) -> String {
format!(
"/api/w/{}/tickets/backend",
self.client.workspace_id().unwrap_or_default()
)
}
fn invoke(
&self,
operation: TicketBackendOperation,
) -> TicketResult<TicketBackendOperationResult> {
let client = self.client.clone();
let endpoint = self.endpoint();
let workspace_id = self.client.workspace_id().unwrap_or_default().to_string();
if tokio::runtime::Handle::try_current().is_ok() {
return std::thread::spawn(move || Self::invoke_client(client, endpoint, operation))
.join()
.map_err(|_| {
TicketError::Conflict("ticket backend request thread panicked".to_string())
})?;
return std::thread::spawn(move || {
Self::invoke_client(client, workspace_id, operation)
})
.join()
.map_err(|_| {
TicketError::Conflict("ticket REST request thread panicked".to_string())
})?;
}
Self::invoke_client(client, endpoint, operation)
Self::invoke_client(client, workspace_id, operation)
}
fn ticket_path(id: &TicketIdOrSlug) -> String {
let value = match id {
TicketIdOrSlug::Id(value)
| TicketIdOrSlug::Slug(value)
| TicketIdOrSlug::Query(value) => value,
};
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
encoded.push(byte as char);
} else {
use std::fmt::Write as _;
let _ = write!(encoded, "%{byte:02X}");
}
}
encoded
}
fn request<T: serde::de::DeserializeOwned>(
client: Arc<dyn WorkspaceClient>,
method: WorkspaceRequestMethod,
endpoint: String,
body: Option<serde_json::Value>,
) -> TicketResult<T> {
let request = match body {
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint),
None => WorkspaceRequest {
method,
path: endpoint,
body: None,
},
};
let response = client.execute(request).map_err(|error| {
TicketError::Conflict(format!("ticket REST request failed: {error}"))
})?;
if !response.is_success() {
return Err(TicketError::Conflict(format!(
"ticket REST API returned HTTP {}: {}",
response.status, response.body
)));
}
serde_json::from_str(&response.body)
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
}
fn request_unit(
client: Arc<dyn WorkspaceClient>,
method: WorkspaceRequestMethod,
endpoint: String,
body: Option<serde_json::Value>,
) -> TicketResult<TicketBackendOperationResult> {
let request = match body {
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
None => WorkspaceRequest {
method,
path: endpoint,
body: None,
},
};
let response = client.execute(request).map_err(|error| {
TicketError::Conflict(format!("ticket REST request failed: {error}"))
})?;
if !response.is_success() {
return Err(TicketError::Conflict(format!(
"ticket REST API returned HTTP {}: {}",
response.status, response.body
)));
}
Ok(TicketBackendOperationResult::Unit)
}
fn invoke_client(
client: Arc<dyn WorkspaceClient>,
endpoint: String,
workspace_id: String,
operation: TicketBackendOperation,
) -> TicketResult<TicketBackendOperationResult> {
let body = serde_json::to_string(&operation).map_err(|error| {
TicketError::Conflict(format!("serialize ticket operation: {error}"))
})?;
let response = client
.execute(WorkspaceRequest::json(
let base = format!("/api/w/{workspace_id}/tickets");
match operation {
TicketBackendOperation::DefaultIntakeReadyStateChangeBody { from } => {
let value = Self::request::<String>(
client,
WorkspaceRequestMethod::Post,
format!("{base}/default-intake-ready-body"),
Some(serde_json::json!({ "from": from })),
)?;
Ok(TicketBackendOperationResult::Text(value))
}
TicketBackendOperation::List { filter } => {
let state = match filter.state {
ticket::TicketStateSelector::Active => "active".to_string(),
ticket::TicketStateSelector::All => "all".to_string(),
ticket::TicketStateSelector::States(states) => states
.into_iter()
.map(|state| state.as_str().to_string())
.collect::<Vec<_>>()
.join(","),
};
let tickets = Self::request(
client,
WorkspaceRequestMethod::Get,
format!("{base}/search?state={state}"),
None,
)?;
Ok(TicketBackendOperationResult::Tickets(tickets))
}
TicketBackendOperation::Show { id } => {
let ticket = Self::request(
client,
WorkspaceRequestMethod::Get,
format!("{base}/{}/record", Self::ticket_path(&id)),
None,
)?;
Ok(TicketBackendOperationResult::Ticket(ticket))
}
TicketBackendOperation::Create { input } => {
let ticket = Self::request(
client,
WorkspaceRequestMethod::Post,
base,
Some(serde_json::to_value(input).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket create: {error}"))
})?),
)?;
Ok(TicketBackendOperationResult::TicketRef(ticket))
}
TicketBackendOperation::EditItem { id, edit } => {
let ticket = Self::request(
client,
WorkspaceRequestMethod::Patch,
format!("{base}/{}/item", Self::ticket_path(&id)),
Some(serde_json::to_value(edit).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket edit: {error}"))
})?),
)?;
Ok(TicketBackendOperationResult::Ticket(ticket))
}
TicketBackendOperation::DependencyCheck { id } => {
let check = Self::request(
client,
WorkspaceRequestMethod::Get,
format!("{base}/{}/dependency-check", Self::ticket_path(&id)),
None,
)?;
Ok(TicketBackendOperationResult::DependencyCheck(check))
}
TicketBackendOperation::AddEvent { id, event } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
endpoint,
body,
))
.map_err(|error| {
TicketError::Conflict(format!("ticket backend request failed: {error}"))
})?;
if !response.is_success() {
return Err(TicketError::Conflict(format!(
"ticket backend returned HTTP {}: {}",
response.status, response.body
)));
}
match serde_json::from_str::<TicketBackendHttpResponse>(&response.body).map_err(
|error| TicketError::Conflict(format!("decode ticket backend response: {error}")),
)? {
TicketBackendHttpResponse::Ok { result } => Ok(result),
TicketBackendHttpResponse::Error { message } => Err(TicketError::Conflict(message)),
format!("{base}/{}/thread-events", Self::ticket_path(&id)),
Some(serde_json::to_value(event).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket event: {error}"))
})?),
),
TicketBackendOperation::AddStateChanged { id, change } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/state-changes", Self::ticket_path(&id)),
Some(serde_json::to_value(change).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket state change: {error}"))
})?),
),
TicketBackendOperation::AddIntakeSummary { id, summary } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/intake-summaries", Self::ticket_path(&id)),
Some(serde_json::to_value(summary).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket intake summary: {error}"))
})?),
),
TicketBackendOperation::SetStateField { id, field, change } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
format!(
"{base}/{}/state-fields/{}",
Self::ticket_path(&id),
Self::ticket_path(&TicketIdOrSlug::Query(field))
),
Some(serde_json::to_value(change).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket state field change: {error}"))
})?),
),
TicketBackendOperation::SetWorkflowState { id, change } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/workflow-state", Self::ticket_path(&id)),
Some(serde_json::to_value(change).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket workflow change: {error}"))
})?),
),
TicketBackendOperation::MarkIntakeReady {
id,
summary,
change,
} => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/intake-ready", Self::ticket_path(&id)),
Some(serde_json::json!({ "summary": summary, "change": change })),
),
TicketBackendOperation::QueueReady { id, .. } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/workflow/queue", Self::ticket_path(&id)),
None,
),
TicketBackendOperation::Review { id, review } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/workflow/review", Self::ticket_path(&id)),
Some(serde_json::to_value(review).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket review: {error}"))
})?),
),
TicketBackendOperation::Close { id, resolution } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/workflow/close", Self::ticket_path(&id)),
Some(serde_json::to_value(resolution).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket close: {error}"))
})?),
),
TicketBackendOperation::AddTicketRelation { id, relation } => {
let relation = Self::request(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/relations", Self::ticket_path(&id)),
Some(serde_json::to_value(relation).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket relation: {error}"))
})?),
)?;
Ok(TicketBackendOperationResult::Relation(relation))
}
TicketBackendOperation::QueryTicketRelations { ticket, kind } => {
let relations = Self::request(
client,
WorkspaceRequestMethod::Post,
format!("{base}/relations/search"),
Some(serde_json::json!({ "ticket": ticket, "kind": kind })),
)?;
Ok(TicketBackendOperationResult::Relations(relations))
}
TicketBackendOperation::RelationView { id } => {
let view = Self::request(
client,
WorkspaceRequestMethod::Get,
format!("{base}/{}/relation-view", Self::ticket_path(&id)),
None,
)?;
Ok(TicketBackendOperationResult::RelationView(view))
}
TicketBackendOperation::AddOrchestrationPlanRecord { id, record } => {
let record = Self::request(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/orchestration-plans", Self::ticket_path(&id)),
Some(serde_json::to_value(record).map_err(|error| {
TicketError::Conflict(format!(
"serialize Ticket orchestration plan: {error}"
))
})?),
)?;
Ok(TicketBackendOperationResult::OrchestrationPlanRecord(
record,
))
}
TicketBackendOperation::QueryOrchestrationPlanRecords { ticket, kind } => {
let records = Self::request(
client,
WorkspaceRequestMethod::Post,
format!("{base}/orchestration-plans/search"),
Some(serde_json::json!({ "ticket": ticket, "kind": kind })),
)?;
Ok(TicketBackendOperationResult::OrchestrationPlanRecords(
records,
))
}
TicketBackendOperation::Doctor => {
let report = Self::request(
client,
WorkspaceRequestMethod::Get,
format!("{base}/doctor"),
None,
)?;
Ok(TicketBackendOperationResult::DoctorReport(report))
}
}
}
}
@@ -1129,7 +1382,42 @@ provider = "github"
})
.unwrap_err();
assert!(error.to_string().contains("ticket backend request failed"));
assert!(error.to_string().contains("ticket REST request failed"));
}
#[test]
fn workspace_http_backend_posts_ticket_event_subresource() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let size = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..size]);
assert!(
request
.starts_with("POST /api/w/workspace-a/tickets/01TEST/thread-events HTTP/1.1")
);
assert!(!request.contains("\"operation\""));
assert!(request.contains("\"kind\":\"comment\""));
stream
.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
.unwrap();
});
let client = Arc::new(crate::worker::RuntimeWorkspaceHttpClient::new(
"workspace-a",
format!("http://{address}"),
"worker-a",
));
let backend = WorkspaceHttpTicketBackend::new(client);
backend
.add_event(
TicketIdOrSlug::Id("01TEST".to_string()),
NewTicketEvent::new(ticket::TicketEventKind::Comment, "REST comment"),
)
.unwrap();
server.join().unwrap();
}
#[test]
@@ -1141,15 +1429,13 @@ provider = "github"
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with("POST /api/w/workspace-a/tickets/backend HTTP/1.1"));
assert!(request.contains("\"operation\":\"create\""));
assert!(request.starts_with("POST /api/w/workspace-a/tickets HTTP/1.1"));
assert!(!request.contains("\"operation\""));
assert!(request.contains("\"title\":\"HTTP ticket\""));
let response_body = serde_json::to_string(&TicketBackendHttpResponse::Ok {
result: TicketBackendOperationResult::TicketRef(TicketRef {
id: "01TEST".to_string(),
slug: "http-ticket".to_string(),
status: ticket::TicketStatus::Open,
}),
let response_body = serde_json::to_string(&TicketRef {
id: "01TEST".to_string(),
slug: "http-ticket".to_string(),
status: ticket::TicketStatus::Open,
})
.unwrap();
write!(
+30 -1
View File
@@ -215,6 +215,13 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
fn is_available(&self) -> bool;
fn execute(&self, request: WorkspaceRequest)
-> Result<WorkspaceResponse, WorkspaceClientError>;
/// Replace the Runtime-issued Workspace access token for this live client.
fn replace_access_token(&self, _access_token: String) -> Result<(), WorkspaceClientError> {
Err(WorkspaceClientError::Unavailable(
"Workspace client does not support access token replacement".to_string(),
))
}
}
/// HTTP forwarding client created by Runtime for one concrete Worker execution.
@@ -323,6 +330,13 @@ impl WorkspaceClient for RuntimeWorkspaceHttpClient {
}
Ok(result.0)
}
fn replace_access_token(&self, access_token: String) -> Result<(), WorkspaceClientError> {
*self.access_token.lock().map_err(|_| {
WorkspaceClientError::Request("workspace credential lock poisoned".to_string())
})? = Some(access_token);
Ok(())
}
}
fn execute_runtime_workspace_http_with_refresh(
@@ -6318,13 +6332,28 @@ mod build_summary_prompt_tests {
.with_access_token(Some("expired-token".to_string()));
let response = client
.execute(WorkspaceRequest::get(
"/api/w/workspace-refresh/tickets/backend",
"/api/w/workspace-refresh/tickets/search",
))
.unwrap();
assert_eq!(response.status, 200);
server.join().unwrap();
}
#[test]
fn runtime_workspace_client_can_install_missing_access_token() {
let client =
RuntimeWorkspaceHttpClient::new("workspace-a", "https://workspace.example", "worker-a");
client
.replace_access_token("replacement-token".to_string())
.unwrap();
assert_eq!(
client.access_token.lock().unwrap().as_deref(),
Some("replacement-token")
);
}
fn minimal_manifest() -> WorkerManifest {
let toml_str = r#"
[worker]