diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index c67d5b7..ae3c1e2 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "ed1fbc0c464627dc5cd56d5b01343bd084e04c95", - "release_name": "dryrun-ed1fbc0c4646", + "source_commit": "3cea82a67d5b5aca88470c0d06677b8b4237da11", + "release_name": "dryrun-3cea82a67d5b", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index abe365b..2d3585f 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -558,6 +558,7 @@ enum CliSession { Anonymous, HumanSession, AgentPublicKey { + agent: String, public_key_fingerprint: Digest, browser_interaction_required: bool, }, @@ -2285,11 +2286,13 @@ fn auth_state_value() -> Value { }) } CliSession::AgentPublicKey { + agent, public_key_fingerprint, browser_interaction_required, } => json!({ "kind": "agent_public_key", "authenticated": true, + "agent": agent, "source": "DISASMER_AGENT_PUBLIC_KEY", "public_key_fingerprint": public_key_fingerprint, "browser_interaction_required": browser_interaction_required, @@ -3415,13 +3418,15 @@ fn coordinator_run_report(plan: RunPlan) -> Result { let coordinator = run_coordinator_endpoint(&plan)?; let process = "vp-current".to_owned(); let mut session = JsonLineSession::connect(&coordinator)?; - let response = session.request_allow_error(json!({ + let mut request = json!({ "type": "start_process", "tenant": tenant.clone(), "project": project.clone(), "process": process.clone(), "restart": false, - }))?; + }); + add_workflow_actor_fields(&mut request, &plan.session, &user); + let response = session.request_allow_error(request)?; let run_start = run_start_summary(&response); let status = run_start .get("status") @@ -3435,6 +3440,7 @@ fn coordinator_run_report(plan: RunPlan) -> Result { "tenant": tenant, "project": project, "user": user, + "workflow_actor": response.get("actor").cloned().unwrap_or(Value::Null), "coordinator": coordinator, "process": process, "run_start": run_start, @@ -3464,6 +3470,7 @@ fn run_start_summary(response: &Value) -> Value { "accepted": true, "process": response.get("process").cloned().unwrap_or(Value::Null), "coordinator_epoch": response.get("epoch").cloned().unwrap_or(Value::Null), + "actor": response.get("actor").cloned().unwrap_or(Value::Null), "restart": false, "single_active_process_boundary": true, "next_actions": [ @@ -3712,6 +3719,7 @@ fn session_from_env() -> CliSession { if let Some(public_key) = std::env::var_os("DISASMER_AGENT_PUBLIC_KEY") { let public_key = public_key.to_string_lossy(); return CliSession::AgentPublicKey { + agent: std::env::var("DISASMER_AGENT_ID").unwrap_or_else(|_| "agent".to_owned()), public_key_fingerprint: Digest::sha256(public_key.as_bytes()), browser_interaction_required: false, }; @@ -3722,6 +3730,28 @@ fn session_from_env() -> CliSession { CliSession::Anonymous } +fn add_workflow_actor_fields(request: &mut Value, session: &CliSession, fallback_user: &str) { + let Value::Object(map) = request else { + return; + }; + match session { + CliSession::AgentPublicKey { + agent, + public_key_fingerprint, + .. + } => { + map.insert("actor_agent".to_owned(), json!(agent)); + map.insert( + "agent_public_key_fingerprint".to_owned(), + json!(public_key_fingerprint), + ); + } + CliSession::HumanSession | CliSession::Anonymous => { + map.insert("actor_user".to_owned(), json!(fallback_user)); + } + } +} + fn attach_plan(args: AttachArgs) -> NodeAttachPlan { let mut capabilities = NodeCapabilities::detect_current(); for cap in &args.caps { @@ -3989,6 +4019,7 @@ mod tests { args, PathBuf::from("/repo"), CliSession::AgentPublicKey { + agent: "agent-ci".to_owned(), public_key_fingerprint: Digest::sha256("agent-key"), browser_interaction_required: false, }, @@ -4003,12 +4034,83 @@ mod tests { assert_eq!( plan.session, CliSession::AgentPublicKey { + agent: "agent-ci".to_owned(), public_key_fingerprint: Digest::sha256("agent-key"), browser_interaction_required: false, } ); } + #[test] + fn run_with_agent_public_key_sends_attributable_workflow_actor() { + let temp = tempfile::tempdir().unwrap(); + write_project_config( + temp.path(), + &ProjectConfig { + tenant: "tenant-live".to_owned(), + project: "project-live".to_owned(), + user: "user-live".to_owned(), + coordinator: None, + }, + ) + .unwrap(); + + let fingerprint = Digest::sha256("agent-key"); + let expected_fingerprint = fingerprint.to_string(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"start_process""#)); + assert!(line.contains(r#""tenant":"tenant-live""#)); + assert!(line.contains(r#""project":"project-live""#)); + assert!(line.contains(r#""actor_agent":"agent-ci""#)); + assert!(line.contains(&format!( + r#""agent_public_key_fingerprint":"{expected_fingerprint}""# + ))); + assert!(!line.contains(r#""actor_user""#)); + stream + .write_all( + format!( + r#"{{"type":"process_started","process":"vp-current","epoch":7,"actor":{{"kind":"agent","user":"user-live","agent":"agent-ci","credential_kind":"public_key","public_key_fingerprint":"{expected_fingerprint}","authenticated_without_browser":true,"scopes":["project:read","project:run"]}}}}"# + ) + .as_bytes(), + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + }); + + let report = run_report( + RunArgs { + entry: Some("build".to_owned()), + project: Some(temp.path().to_path_buf()), + coordinator: Some(format!("http://{addr}")), + local: false, + json: false, + }, + PathBuf::from("/unused"), + CliSession::AgentPublicKey { + agent: "agent-ci".to_owned(), + public_key_fingerprint: fingerprint, + browser_interaction_required: false, + }, + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(report["status"], "started"); + assert_eq!(report["workflow_actor"]["kind"], "agent"); + assert_eq!(report["workflow_actor"]["agent"], "agent-ci"); + assert_eq!( + report["workflow_actor"]["authenticated_without_browser"], + true + ); + assert_eq!(report["run_start"]["actor"]["kind"], "agent"); + } + #[test] fn run_local_flag_overrides_logged_in_hosted_selection() { let Cli { diff --git a/crates/disasmer-coordinator/src/lib.rs b/crates/disasmer-coordinator/src/lib.rs index 1f50575..74048f6 100644 --- a/crates/disasmer-coordinator/src/lib.rs +++ b/crates/disasmer-coordinator/src/lib.rs @@ -473,6 +473,40 @@ impl Coordinator { Ok(record.clone()) } + pub fn authorize_agent_project_run( + &self, + tenant: &TenantId, + project: &ProjectId, + agent: &AgentId, + public_key_fingerprint: &Digest, + ) -> Result { + let record = self + .durable + .agent_public_keys + .get(&(tenant.clone(), project.clone(), agent.clone())) + .ok_or_else(|| { + CoordinatorError::Unauthorized( + "agent public key is not registered for this tenant/project".to_owned(), + ) + })?; + if record.revoked { + return Err(CoordinatorError::Unauthorized( + "agent public key has been revoked".to_owned(), + )); + } + if &record.public_key_fingerprint != public_key_fingerprint { + return Err(CoordinatorError::Unauthorized( + "agent public key fingerprint does not match the registered key".to_owned(), + )); + } + if !record.scopes.iter().any(|scope| scope == "project:run") { + return Err(CoordinatorError::Unauthorized( + "agent public key is not scoped for project runs".to_owned(), + )); + } + Ok(record.clone()) + } + pub fn start_process( &mut self, tenant: TenantId, diff --git a/crates/disasmer-coordinator/src/service.rs b/crates/disasmer-coordinator/src/service.rs index e2d1844..4499b82 100644 --- a/crates/disasmer-coordinator/src/service.rs +++ b/crates/disasmer-coordinator/src/service.rs @@ -147,7 +147,12 @@ pub enum CoordinatorRequest { LaunchTask { tenant: String, project: String, - actor_user: String, + #[serde(default)] + actor_user: Option, + #[serde(default)] + actor_agent: Option, + #[serde(default)] + agent_public_key_fingerprint: Option, process: String, task: String, environment: Option, @@ -193,6 +198,12 @@ pub enum CoordinatorRequest { StartProcess { tenant: String, project: String, + #[serde(default)] + actor_user: Option, + #[serde(default)] + actor_agent: Option, + #[serde(default)] + agent_public_key_fingerprint: Option, process: String, #[serde(default)] restart: bool, @@ -426,6 +437,17 @@ pub struct DebugAuditEvent { pub used_debug_read_bytes: u64, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkflowActor { + pub kind: String, + pub user: Option, + pub agent: Option, + pub credential_kind: CredentialKind, + pub public_key_fingerprint: Option, + pub authenticated_without_browser: bool, + pub scopes: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TaskAssignment { pub tenant: TenantId, @@ -539,6 +561,7 @@ pub enum CoordinatorResponse { TaskLaunched { process: ProcessId, task: TaskId, + actor: WorkflowActor, placement: Placement, assignment: TaskAssignment, }, @@ -560,6 +583,7 @@ pub enum CoordinatorResponse { ProcessStarted { process: ProcessId, epoch: u64, + actor: WorkflowActor, }, NodeReconnected { node: NodeId, @@ -1222,6 +1246,8 @@ impl CoordinatorService { tenant, project, actor_user, + actor_agent, + agent_public_key_fingerprint, process, task, environment, @@ -1240,9 +1266,13 @@ impl CoordinatorService { let project = ProjectId::new(project); let process = ProcessId::new(process); let task = TaskId::new(task); - let actor = UserId::new(actor_user); - self.coordinator - .upsert_user(tenant.clone(), actor, CredentialKind::BrowserSession); + let actor = self.workflow_actor( + &tenant, + &project, + actor_user, + actor_agent, + agent_public_key_fingerprint, + )?; let active = self.coordinator.active_process(&process).ok_or_else(|| { CoordinatorError::Unauthorized( "task launch requires an active coordinator-side virtual process" @@ -1309,6 +1339,7 @@ impl CoordinatorService { Ok(CoordinatorResponse::TaskLaunched { process, task, + actor, placement, assignment, }) @@ -1445,6 +1476,9 @@ impl CoordinatorService { CoordinatorRequest::StartProcess { tenant, project, + actor_user, + actor_agent, + agent_public_key_fingerprint, process, restart, } => { @@ -1452,6 +1486,13 @@ impl CoordinatorService { let project = ProjectId::new(project); let process = ProcessId::new(process); self.coordinator.ensure_tenant_active(&tenant)?; + let actor = self.workflow_actor( + &tenant, + &project, + actor_user, + actor_agent, + agent_public_key_fingerprint, + )?; if let Some(active) = self .coordinator .active_process_for_project(&tenant, &project) @@ -1492,6 +1533,7 @@ impl CoordinatorService { Ok(CoordinatorResponse::ProcessStarted { process, epoch: self.coordinator.coordinator_epoch(), + actor, }) } CoordinatorRequest::ReconnectNode { @@ -2245,6 +2287,55 @@ impl CoordinatorService { Ok(event) } + fn workflow_actor( + &mut self, + tenant: &TenantId, + project: &ProjectId, + actor_user: Option, + actor_agent: Option, + agent_public_key_fingerprint: Option, + ) -> Result { + if let Some(agent) = actor_agent { + let agent = AgentId::new(agent); + let public_key_fingerprint = agent_public_key_fingerprint.ok_or_else(|| { + CoordinatorError::Unauthorized( + "agent workflow dispatch requires public key fingerprint".to_owned(), + ) + })?; + let record = self.coordinator.authorize_agent_project_run( + tenant, + project, + &agent, + &public_key_fingerprint, + )?; + return Ok(WorkflowActor { + kind: "agent".to_owned(), + user: Some(record.user), + agent: Some(agent), + credential_kind: CredentialKind::PublicKey, + public_key_fingerprint: Some(public_key_fingerprint), + authenticated_without_browser: true, + scopes: record.scopes, + }); + } + + let actor = UserId::new(actor_user.unwrap_or_else(|| "user".to_owned())); + self.coordinator.upsert_user( + tenant.clone(), + actor.clone(), + CredentialKind::BrowserSession, + ); + Ok(WorkflowActor { + kind: "user".to_owned(), + user: Some(actor), + agent: None, + credential_kind: CredentialKind::BrowserSession, + public_key_fingerprint: None, + authenticated_without_browser: false, + scopes: vec!["project:read".to_owned(), "project:run".to_owned()], + }) + } + fn export_endpoint( &self, node: &NodeId, @@ -2681,6 +2772,16 @@ mod tests { } } + fn assert_agent_workflow_actor(actor: &WorkflowActor, fingerprint: &Digest) { + assert_eq!(actor.kind, "agent"); + assert_eq!(actor.user, Some(UserId::from("user"))); + assert_eq!(actor.agent, Some(AgentId::from("agent-ci"))); + assert_eq!(actor.credential_kind, CredentialKind::PublicKey); + assert_eq!(actor.public_key_fingerprint.as_ref(), Some(fingerprint)); + assert!(actor.authenticated_without_browser); + assert!(actor.scopes.iter().any(|scope| scope == "project:run")); + } + #[test] fn service_creates_selects_and_lists_signed_in_user_projects() { let mut service = CoordinatorService::new(7); @@ -2812,6 +2913,9 @@ mod tests { .handle_request(CoordinatorRequest::StartProcess { tenant: "tenant".to_owned(), project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, process: "process".to_owned(), restart: false, }) @@ -2926,6 +3030,160 @@ mod tests { assert!(record.revoked); } + #[test] + fn service_runs_agent_workflows_with_scoped_key_attribution() { + let mut service = CoordinatorService::new(7); + let agent_fingerprint = Digest::sha256("agent-key-v1"); + + service + .handle_request(CoordinatorRequest::RegisterAgentPublicKey { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + agent: "agent-ci".to_owned(), + public_key: "agent-key-v1".to_owned(), + }) + .unwrap(); + + let bad_key = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: Some("agent-ci".to_owned()), + agent_public_key_fingerprint: Some(Digest::sha256("other-key")), + process: "vp-agent-bad-key".to_owned(), + restart: false, + }) + .unwrap_err(); + assert!(bad_key.to_string().contains("fingerprint")); + + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + public_key: "worker-linux-public-key".to_owned(), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap(); + + let CoordinatorResponse::ProcessStarted { + actor: start_actor, .. + } = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: Some("agent-ci".to_owned()), + agent_public_key_fingerprint: Some(agent_fingerprint.clone()), + process: "vp-agent".to_owned(), + restart: false, + }) + .unwrap() + else { + panic!("expected agent-authenticated process start"); + }; + assert_agent_workflow_actor(&start_actor, &agent_fingerprint); + + let CoordinatorResponse::TaskLaunched { + actor: launch_actor, + placement, + assignment, + .. + } = service + .handle_request(CoordinatorRequest::LaunchTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: Some("agent-ci".to_owned()), + agent_public_key_fingerprint: Some(agent_fingerprint.clone()), + process: "vp-agent".to_owned(), + task: "compile-linux".to_owned(), + environment: None, + environment_digest: None, + required_capabilities: vec![Capability::Command], + dependency_cache: None, + source_snapshot: None, + required_artifacts: Vec::new(), + quota_available: true, + policy_allowed: true, + command: "cargo".to_owned(), + command_args: vec!["test".to_owned()], + artifact_path: "/vfs/artifacts/dap-output.txt".to_owned(), + }) + .unwrap() + else { + panic!("expected agent-authenticated task launch"); + }; + assert_agent_workflow_actor(&launch_actor, &agent_fingerprint); + assert_eq!(placement.node, NodeId::from("worker-linux")); + assert_eq!(assignment.node, NodeId::from("worker-linux")); + + let quota_error = service + .handle_request(CoordinatorRequest::LaunchTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: Some("agent-ci".to_owned()), + agent_public_key_fingerprint: Some(agent_fingerprint.clone()), + process: "vp-agent".to_owned(), + task: "compile-linux-quota".to_owned(), + environment: None, + environment_digest: None, + required_capabilities: vec![Capability::Command], + dependency_cache: None, + source_snapshot: None, + required_artifacts: Vec::new(), + quota_available: false, + policy_allowed: true, + command: "cargo".to_owned(), + command_args: vec!["test".to_owned()], + artifact_path: "/vfs/artifacts/quota.txt".to_owned(), + }) + .unwrap_err(); + assert!(quota_error + .to_string() + .contains("quota unavailable for placement")); + + let policy_error = service + .handle_request(CoordinatorRequest::LaunchTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: Some("agent-ci".to_owned()), + agent_public_key_fingerprint: Some(agent_fingerprint), + process: "vp-agent".to_owned(), + task: "compile-linux-policy".to_owned(), + environment: None, + environment_digest: None, + required_capabilities: vec![Capability::Command], + dependency_cache: None, + source_snapshot: None, + required_artifacts: Vec::new(), + quota_available: true, + policy_allowed: false, + command: "cargo".to_owned(), + command_args: vec!["test".to_owned()], + artifact_path: "/vfs/artifacts/policy.txt".to_owned(), + }) + .unwrap_err(); + assert!(policy_error.to_string().contains("policy denied placement")); + } + #[test] fn service_attaches_node_starts_process_and_records_scoped_task_event() { let mut service = CoordinatorService::new(7); @@ -2944,6 +3202,9 @@ mod tests { .handle_request(CoordinatorRequest::StartProcess { tenant: "tenant".to_owned(), project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, process: "process".to_owned(), restart: false, }) @@ -2952,7 +3213,16 @@ mod tests { started, CoordinatorResponse::ProcessStarted { process: ProcessId::from("process"), - epoch: 7 + epoch: 7, + actor: WorkflowActor { + kind: "user".to_owned(), + user: Some(UserId::from("user")), + agent: None, + credential_kind: CredentialKind::BrowserSession, + public_key_fingerprint: None, + authenticated_without_browser: false, + scopes: vec!["project:read".to_owned(), "project:run".to_owned()], + } } ); @@ -3178,6 +3448,9 @@ mod tests { .handle_request(CoordinatorRequest::StartProcess { tenant: "tenant".to_owned(), project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, process: "process".to_owned(), restart: false, }) @@ -3294,6 +3567,9 @@ mod tests { .handle_request(CoordinatorRequest::StartProcess { tenant: "tenant".to_owned(), project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, process: "process".to_owned(), restart: false, }) @@ -3390,6 +3666,9 @@ mod tests { .handle_request(CoordinatorRequest::StartProcess { tenant: "tenant".to_owned(), project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, process: "process".to_owned(), restart: false, }) @@ -3467,7 +3746,9 @@ mod tests { .handle_request(CoordinatorRequest::LaunchTask { tenant: "tenant".to_owned(), project: "project".to_owned(), - actor_user: "user".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, process: "process".to_owned(), task: "task".to_owned(), environment: None, @@ -3599,6 +3880,9 @@ mod tests { .handle_request(CoordinatorRequest::StartProcess { tenant: "tenant".to_owned(), project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, process: "process".to_owned(), restart: false, }) @@ -3621,7 +3905,9 @@ mod tests { .handle_request(CoordinatorRequest::LaunchTask { tenant: "tenant".to_owned(), project: "project".to_owned(), - actor_user: "user".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, process: "process".to_owned(), task: task.to_owned(), environment: None, @@ -3687,7 +3973,9 @@ mod tests { .handle_request(CoordinatorRequest::LaunchTask { tenant: "tenant".to_owned(), project: "project".to_owned(), - actor_user: "user".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, process: "process".to_owned(), task: "package-linux".to_owned(), environment: None, @@ -3715,6 +4003,9 @@ mod tests { .handle_request(CoordinatorRequest::StartProcess { tenant: "tenant".to_owned(), project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, process: "process-a".to_owned(), restart: false, }) @@ -3728,6 +4019,9 @@ mod tests { .handle_request(CoordinatorRequest::StartProcess { tenant: "tenant".to_owned(), project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, process: "process-a".to_owned(), restart: false, }) @@ -3740,6 +4034,9 @@ mod tests { .handle_request(CoordinatorRequest::StartProcess { tenant: "tenant".to_owned(), project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, process: "process-b".to_owned(), restart: true, }) @@ -3752,6 +4049,9 @@ mod tests { .handle_request(CoordinatorRequest::StartProcess { tenant: "tenant".to_owned(), project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, process: "process-a".to_owned(), restart: true, }) @@ -3761,6 +4061,15 @@ mod tests { CoordinatorResponse::ProcessStarted { process: ProcessId::from("process-a"), epoch: 7, + actor: WorkflowActor { + kind: "user".to_owned(), + user: Some(UserId::from("user")), + agent: None, + credential_kind: CredentialKind::BrowserSession, + public_key_fingerprint: None, + authenticated_without_browser: false, + scopes: vec!["project:read".to_owned(), "project:run".to_owned()], + } } ); } @@ -3780,6 +4089,9 @@ mod tests { .handle_request(CoordinatorRequest::StartProcess { tenant: "tenant".to_owned(), project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, process: "process".to_owned(), restart: false, }) @@ -4129,6 +4441,9 @@ mod tests { .handle_request(CoordinatorRequest::StartProcess { tenant: "tenant".to_owned(), project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, process: "process".to_owned(), restart: false, }) @@ -4304,6 +4619,9 @@ mod tests { .handle_request(CoordinatorRequest::StartProcess { tenant: "tenant".to_owned(), project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, process: "process".to_owned(), restart: false, }) @@ -4556,6 +4874,9 @@ mod tests { .handle_request(CoordinatorRequest::StartProcess { tenant: "tenant".to_owned(), project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, process: "vp-control".to_owned(), restart: false, }) @@ -4569,11 +4890,14 @@ mod tests { task, placement, assignment, + .. } = service .handle_request(CoordinatorRequest::LaunchTask { tenant: "tenant".to_owned(), project: "project".to_owned(), - actor_user: "user".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, process: "vp-control".to_owned(), task: "compile-linux".to_owned(), environment: None, @@ -4634,6 +4958,9 @@ mod tests { .handle_request(CoordinatorRequest::StartProcess { tenant: "tenant".to_owned(), project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, process: "vp-control".to_owned(), restart: false, }) @@ -4643,7 +4970,9 @@ mod tests { .handle_request(CoordinatorRequest::LaunchTask { tenant: "tenant".to_owned(), project: "project".to_owned(), - actor_user: "user".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, process: "vp-control".to_owned(), task: "compile-linux".to_owned(), environment: None, @@ -4768,6 +5097,9 @@ mod tests { .handle_request(CoordinatorRequest::StartProcess { tenant: "tenant-b".to_owned(), project: "project-b".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, process: "process".to_owned(), restart: false, }) diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index 0b98034..cf70c29 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -106,6 +106,7 @@ for (const [name, pattern] of [ ["CLI JSON parse coverage", /fn cli_first_json_mode_parses_for_primary_commands\(\)/], ["CLI human output coverage", /fn human_report_is_text_not_json\(\)/], ["CLI key lifecycle coverage", /fn key_lifecycle_reports_project_scoped_agent_credentials\(\)/], + ["CLI agent workflow actor coverage", /fn run_with_agent_public_key_sends_attributable_workflow_actor\(\)/], ["CLI node revoke coverage", /fn node_revoke_reports_scoped_credential_revocation\(\)/], ["CLI admin public API coverage", /fn admin_status_and_suspend_use_public_coordinator_api\(\)/], ["CLI debug attach coverage", /fn debug_attach_reports_public_authorization\(\)/], @@ -141,6 +142,21 @@ expect( "coordinator agent key lifecycle coverage", /fn service_manages_project_scoped_agent_public_keys\(\)/ ); +expect( + coordinator, + "coordinator agent workflow dispatch coverage", + /fn service_runs_agent_workflows_with_scoped_key_attribution\(\)/ +); +expect( + coordinator, + "coordinator agent workflow authorization", + /authorize_agent_project_run\([\s\S]*project:run/ +); +expect( + coordinator, + "coordinator agent workflow actor fields", + /pub struct WorkflowActor[\s\S]*agent: Option[\s\S]*public_key_fingerprint: Option[\s\S]*authenticated_without_browser/ +); expect( coordinator, "coordinator node revoke coverage", @@ -176,6 +192,11 @@ expect( "CLI surfaces debug audit and quota fields", /debug_reads_quota_limited[\s\S]*charged_debug_read_bytes[\s\S]*used_debug_read_bytes/ ); +expect( + cli, + "CLI sends agent workflow actor fields", + /fn add_workflow_actor_fields[\s\S]*actor_agent[\s\S]*agent_public_key_fingerprint/ +); for (const [name, pattern] of [ ["agent --json flag", /struct AgentEnrollArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/],