Unify run event principals

This commit is contained in:
Bryan Helmkamp 2026-05-01 21:56:47 -04:00
parent 56a2257d8a
commit 8d7b9a804a
No known key found for this signature in database
86 changed files with 2570 additions and 1580 deletions

View file

@ -72,6 +72,10 @@ jobs:
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
with:
cache-on-failure: true
- name: Verify legacy auth identity removal
run: |
! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*"disabled"' \
lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml
- run: cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings
generated-docs:

View file

@ -41,6 +41,12 @@ Each serialized `RunEvent` uses this canonical envelope:
"parent_session_id": "ses_parent",
"node_id": "code",
"node_label": "Code",
"actor": {
"kind": "agent",
"session_id": "ses_child",
"parent_session_id": "ses_parent",
"model": "gpt-5.2"
},
"properties": {
"tool_name": "read_file",
"tool_call_id": "call_1",
@ -66,6 +72,7 @@ Optional top-level fields:
| `parent_session_id` | Forwarded child-session events |
| `node_id` | Events tied to a graph node or branch |
| `node_label` | Display label for `node_id`; omitted when not applicable |
| `actor` | The principal responsible for the event |
Everything else lives inside `properties`.
@ -73,6 +80,8 @@ Important rules:
- Optional envelope fields are omitted, not serialized as `null`.
- Event-specific fields do not get flattened into the top level.
- Actor identity lives only in top-level `actor: Principal`; never duplicate it in event-specific properties.
- User actors must carry canonical IdP identity through `Principal::User { identity, login, auth_method }`, not a login-only string.
- `EventPayload` validation requires `id`, `ts`, `run_id`, and `event`.
## Naming

View file

@ -118,6 +118,11 @@ Fields are key-value pairs that make events queryable. Include enough context th
| `error` | Error value on failure |
| `path` | File system path |
| `duration_ms` | Elapsed time in milliseconds |
| `principal_kind` | HTTP caller category (`user`, `worker`, `webhook`, `anonymous`, etc.) |
| `auth_status` | HTTP authentication result (`missing`, `invalid`, `expired`, `authenticated`) |
| `idp_issuer`, `idp_subject` | Canonical user identity for authenticated user requests |
For HTTP request logs, use the request `Principal` projection rather than hand-assembled auth strings. User identity fields are present only for `Principal::User`; worker and webhook requests use their variant-specific fields (`run_id`, `delivery_id`).
| `input_tokens` | Token count for LLM input |
| `output_tokens` | Token count for LLM output |

View file

@ -4724,31 +4724,145 @@ components:
items:
type: string
ActorKind:
description: High-level category of an event actor.
AuthMethod:
description: Runtime user authentication method.
type: string
enum:
- user
- agent
- system
- github
- dev_token
ActorRef:
description: >
Optional primary actor associated with a run event. Present on control
actions and durable agent output where a stable user or agent identity
matters; omitted on routine runtime lifecycle events.
SystemActorKind:
type: string
enum:
- engine
- watchdog
- timeout
IdpIdentity:
type: object
required:
- issuer
- subject
properties:
issuer:
type: string
subject:
type: string
Principal:
oneOf:
- $ref: "#/components/schemas/PrincipalUser"
- $ref: "#/components/schemas/PrincipalWorker"
- $ref: "#/components/schemas/PrincipalWebhook"
- $ref: "#/components/schemas/PrincipalSlack"
- $ref: "#/components/schemas/PrincipalAgent"
- $ref: "#/components/schemas/PrincipalSystem"
- $ref: "#/components/schemas/PrincipalAnonymous"
discriminator:
propertyName: kind
mapping:
user: "#/components/schemas/PrincipalUser"
worker: "#/components/schemas/PrincipalWorker"
webhook: "#/components/schemas/PrincipalWebhook"
slack: "#/components/schemas/PrincipalSlack"
agent: "#/components/schemas/PrincipalAgent"
system: "#/components/schemas/PrincipalSystem"
anonymous: "#/components/schemas/PrincipalAnonymous"
PrincipalUser:
type: object
required:
- kind
- identity
- login
- auth_method
properties:
kind:
type: string
enum: [user]
identity:
$ref: "#/components/schemas/IdpIdentity"
login:
type: string
auth_method:
$ref: "#/components/schemas/AuthMethod"
PrincipalWorker:
type: object
required:
- kind
- run_id
properties:
kind:
type: string
enum: [worker]
run_id:
type: string
PrincipalWebhook:
type: object
required:
- kind
- delivery_id
properties:
kind:
type: string
enum: [webhook]
delivery_id:
type: string
PrincipalSlack:
type: object
required:
- kind
- team_id
- user_id
properties:
kind:
type: string
enum: [slack]
team_id:
type: string
user_id:
type: string
user_name:
type: ["string", "null"]
PrincipalAgent:
type: object
required:
- kind
properties:
kind:
$ref: "#/components/schemas/ActorKind"
id:
type: string
description: Stable actor identifier when available.
display:
enum: [agent]
session_id:
type: ["string", "null"]
parent_session_id:
type: ["string", "null"]
model:
type: ["string", "null"]
PrincipalSystem:
type: object
required:
- kind
- system_kind
properties:
kind:
type: string
description: Display-friendly label for the actor.
enum: [system]
system_kind:
$ref: "#/components/schemas/SystemActorKind"
PrincipalAnonymous:
type: object
required:
- kind
properties:
kind:
type: string
enum: [anonymous]
RunEvent:
description: >
@ -4797,7 +4911,7 @@ components:
call.
actor:
oneOf:
- $ref: "#/components/schemas/ActorRef"
- $ref: "#/components/schemas/Principal"
- type: "null"
event:
type: string

View file

@ -309,8 +309,11 @@ fn main() {
"fabro_types::settings::server::WebhookStrategy",
&[],
),
("ActorKind", "fabro_types::ActorKind", &[]),
("ActorRef", "fabro_types::ActorRef", &[]),
("AuthMethod", "fabro_types::AuthMethod", &[]),
("IdpIdentity", "fabro_types::IdpIdentity", &[]),
("Principal", "fabro_types::Principal", &[]),
("PrincipalUser", "fabro_types::UserPrincipal", &[]),
("SystemActorKind", "fabro_types::SystemActorKind", &[]),
("QuestionType", "fabro_types::QuestionType", &[]),
("StageCompletion", "fabro_types::StageCompletion", &[]),
("StageOutcome", "fabro_types::StageOutcome", &[]),

View file

@ -29,11 +29,12 @@ pub mod types {
BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason, TerminalStatus,
};
pub use fabro_types::{
ActorKind, ActorRef, BilledTokenCounts, CommandOutputStream, CommandTermination, DiffStats,
DirtyStatus, EventEnvelope, GitContext, InterviewOption, InterviewQuestionRecord,
PendingInterviewRecord, PreRunPushOutcome, QuestionType, RepositoryReference, RunEvent,
RunProjection, RunSummary, SecretMetadata, SecretType, ServerSettings, StageCompletion,
StageOutcome, StageProjection, StageState, WorkflowSettings,
AuthMethod, BilledTokenCounts, CommandOutputStream, CommandTermination, DiffStats,
DirtyStatus, EventEnvelope, GitContext, IdpIdentity, InterviewOption,
InterviewQuestionRecord, PendingInterviewRecord, PreRunPushOutcome, Principal,
QuestionType, RepositoryReference, RunEvent, RunProjection, RunSummary, SecretMetadata,
SecretType, ServerSettings, StageCompletion, StageOutcome, StageProjection, StageState,
SystemActorKind, UserPrincipal, WorkflowSettings,
};
pub use crate::generated::types::*;

View file

@ -1,52 +0,0 @@
use std::any::{TypeId, type_name};
use fabro_api::types::ActorKind as ApiActorKind;
use fabro_types::ActorKind;
use serde_json::json;
#[test]
fn actor_kind_reuses_canonical_type() {
assert_same_type::<ApiActorKind, ActorKind>();
}
#[test]
fn actor_kind_serializes_as_snake_case_strings() {
assert_eq!(
serde_json::to_value(ActorKind::User).unwrap(),
json!("user")
);
assert_eq!(
serde_json::to_value(ActorKind::Agent).unwrap(),
json!("agent")
);
assert_eq!(
serde_json::to_value(ActorKind::System).unwrap(),
json!("system")
);
}
#[test]
fn actor_kind_deserializes_each_variant() {
assert_eq!(
serde_json::from_value::<ActorKind>(json!("user")).unwrap(),
ActorKind::User
);
assert_eq!(
serde_json::from_value::<ActorKind>(json!("agent")).unwrap(),
ActorKind::Agent
);
assert_eq!(
serde_json::from_value::<ActorKind>(json!("system")).unwrap(),
ActorKind::System
);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -1,51 +0,0 @@
use std::any::{TypeId, type_name};
use fabro_api::types::ActorRef as ApiActorRef;
use fabro_types::{ActorKind, ActorRef};
use serde_json::json;
#[test]
fn actor_ref_reuses_canonical_type() {
assert_same_type::<ApiActorRef, ActorRef>();
}
#[test]
fn actor_ref_round_trips_representative_json() {
let value = json!({
"kind": "agent",
"id": "agent-1",
"display": "Agent 1"
});
let actor: ActorRef = serde_json::from_value(value.clone()).unwrap();
assert_eq!(actor, ActorRef {
kind: ActorKind::Agent,
id: Some("agent-1".to_string()),
display: Some("Agent 1".to_string()),
});
assert_eq!(serde_json::to_value(actor).unwrap(), value);
}
#[test]
fn actor_ref_omits_absent_optional_fields() {
let actor = ActorRef {
kind: ActorKind::System,
id: None,
display: None,
};
assert_eq!(
serde_json::to_value(actor).unwrap(),
json!({"kind": "system"})
);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -0,0 +1,57 @@
use std::any::{TypeId, type_name};
use fabro_api::types::Principal as ApiPrincipal;
use fabro_types::{AuthMethod, IdpIdentity, Principal, SystemActorKind};
use serde_json::json;
#[test]
fn principal_reuses_canonical_type() {
assert_same_type::<ApiPrincipal, Principal>();
}
#[test]
fn principal_round_trips_representative_json() {
let value = json!({
"kind": "user",
"identity": {
"issuer": "https://github.com",
"subject": "12345"
},
"login": "octocat",
"auth_method": "github"
});
let principal: Principal = serde_json::from_value(value.clone()).unwrap();
assert_eq!(
principal,
Principal::user(
IdpIdentity::new("https://github.com", "12345").unwrap(),
"octocat".to_string(),
AuthMethod::Github,
)
);
assert_eq!(serde_json::to_value(principal).unwrap(), value);
}
#[test]
fn principal_system_uses_system_kind_field() {
let principal = Principal::system(SystemActorKind::Watchdog);
assert_eq!(
serde_json::to_value(principal).unwrap(),
json!({
"kind": "system",
"system_kind": "watchdog"
})
);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -66,8 +66,9 @@ fn run_event_round_trips_agent_tool_started() {
"tool_call_id": "call_1",
"actor": {
"kind": "agent",
"id": "ses_child",
"display": "claude-sonnet"
"session_id": "ses_child",
"parent_session_id": "ses_parent",
"model": "claude-sonnet"
},
"properties": {
"tool_name": "Bash",

View file

@ -245,9 +245,10 @@ async fn handle_pending_server_interview(
}
hide_progress(progress_ui, json_output);
let interviewer = ConsoleInterviewer::new(styles);
let answer =
let interviewer = ConsoleInterviewer::new(styles, fabro_types::Principal::anonymous());
let submission =
fabro_interview::Interviewer::ask(&interviewer, api_question_to_question(&question)).await;
let answer = submission.answer;
show_progress(progress_ui, json_output);
if answer_requires_reattach(&answer) {

View file

@ -14,12 +14,14 @@ use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use fabro_config::{ServerSettingsBuilder, Storage};
use fabro_interview::{ControlInterviewer, WorkerControlEnvelope, WorkerControlMessage};
use fabro_interview::{
AnswerSubmission, ControlInterviewer, WorkerControlEnvelope, WorkerControlMessage,
};
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
use fabro_types::settings::InterpString;
use fabro_types::settings::run::RunMode;
use fabro_types::{
ActorRef, ArtifactUpload, EventBody, FailureReason, RunBlobId, RunEvent, RunId,
ArtifactUpload, EventBody, FailureReason, Principal, RunBlobId, RunEvent, RunId,
WorkflowSettings,
};
use fabro_vault::Vault;
@ -235,8 +237,10 @@ async fn apply_worker_control_line(
};
match message.message {
WorkerControlMessage::InterviewAnswer { qid, answer } => {
let _ = interviewer.submit(&qid, answer.into()).await;
WorkerControlMessage::InterviewAnswer { qid, answer, actor } => {
let _ = interviewer
.submit(&qid, AnswerSubmission::new(answer.into(), actor))
.await;
}
WorkerControlMessage::RunCancel => {
cancel_token.store(true, Ordering::SeqCst);
@ -403,14 +407,13 @@ impl RunStoreBackend for HttpRunStore {
}
async fn append_run_event(&self, event: &RunEvent) -> Result<()> {
let seq = self
.with_retries("append run event", || {
let client = self.client.clone_for_reuse();
let run_id = self.run_id;
let event = event.clone();
async move { client.append_run_event(&run_id, &event).await }
})
.await?;
let seq = Box::pin(self.with_retries("append run event", || {
let client = self.client.clone_for_reuse();
let run_id = self.run_id;
let event = event.clone();
async move { client.append_run_event(&run_id, &event).await }
}))
.await?;
self.apply_acknowledged_event(seq, event).await
}
@ -498,7 +501,7 @@ fn update_worker_title_from_event(event: &RunEvent) {
fn stamp_system_worker(mut event: RunEvent) -> RunEvent {
if event.actor.is_none() {
event.actor = Some(ActorRef::system_worker());
event.actor = Some(Principal::worker(event.run_id));
}
event
}
@ -609,7 +612,10 @@ mod tests {
InterviewCompletedProps, InterviewStartedProps, RunCompletedProps, RunControlEffectProps,
RunFailedProps, RunStatusTransitionProps,
};
use fabro_types::{ActorRef, EventBody, FailureReason, QuestionType, SuccessReason, fixtures};
use fabro_types::{
AuthMethod, EventBody, FailureReason, IdpIdentity, Principal, QuestionType, SuccessReason,
fixtures,
};
use fabro_vault::{SecretType, Vault};
use fabro_workflow::event::RunEventSink;
@ -628,7 +634,15 @@ mod tests {
assert!(!super::clone_sandbox_requires_github_credentials("local"));
}
fn running_event(actor: Option<ActorRef>) -> fabro_types::RunEvent {
fn test_user_principal(login: &str) -> Principal {
Principal::user(
IdpIdentity::new("https://github.com", "12345").unwrap(),
login.to_string(),
AuthMethod::Github,
)
}
fn running_event(actor: Option<Principal>) -> fabro_types::RunEvent {
fabro_types::RunEvent {
id: "evt_1".to_string(),
ts: Utc::now(),
@ -744,9 +758,9 @@ mod tests {
fn stamp_system_worker_fills_missing_actor_only() {
let stamped = stamp_system_worker(running_event(None));
assert_eq!(stamped.actor, Some(ActorRef::system_worker()));
assert_eq!(stamped.actor, Some(Principal::worker(fixtures::RUN_1)));
let existing_actor = ActorRef::user("octocat".to_string());
let existing_actor = test_user_principal("octocat");
let stamped = stamp_system_worker(running_event(Some(existing_actor.clone())));
assert_eq!(stamped.actor, Some(existing_actor));
}
@ -782,8 +796,8 @@ mod tests {
let first = first.lock().await;
let second = second.lock().await;
assert_eq!(first[0].actor, Some(ActorRef::system_worker()));
assert_eq!(second[0].actor, Some(ActorRef::system_worker()));
assert_eq!(first[0].actor, Some(Principal::worker(fixtures::RUN_1)));
assert_eq!(second[0].actor, Some(Principal::worker(fixtures::RUN_1)));
}
#[tokio::test]
@ -798,11 +812,11 @@ mod tests {
apply_worker_control_line(
&interviewer,
&cancel_token,
r#"{"v":1,"type":"interview.answer","qid":"q-1","answer":{"kind":"yes"}}"#,
r#"{"v":1,"type":"interview.answer","qid":"q-1","answer":{"kind":"yes"},"actor":{"kind":"system","system_kind":"engine"}}"#,
)
.await;
let answer: fabro_interview::Answer = answer_task.await.unwrap();
let answer = answer_task.await.unwrap().answer;
assert_eq!(answer.value, AnswerValue::Yes);
assert!(!cancel_token.load(Ordering::SeqCst));
}
@ -824,7 +838,7 @@ mod tests {
)
.await;
let answer: fabro_interview::Answer = answer_task.await.unwrap();
let answer = answer_task.await.unwrap().answer;
assert_eq!(answer.value, AnswerValue::Interrupted);
assert!(cancel_token.load(Ordering::SeqCst));
}
@ -835,7 +849,7 @@ mod tests {
read_worker_control_stream_blocking(
std::io::Cursor::new(
b"{\"v\":1,\"type\":\"run.cancel\"}\n{\"v\":1,\"type\":\"interview.answer\",\"qid\":\"q-1\",\"answer\":{\"kind\":\"yes\"}}\n",
b"{\"v\":1,\"type\":\"run.cancel\"}\n{\"v\":1,\"type\":\"interview.answer\",\"qid\":\"q-1\",\"answer\":{\"kind\":\"yes\"},\"actor\":{\"kind\":\"system\",\"system_kind\":\"engine\"}}\n",
),
&event_tx,
);
@ -849,7 +863,7 @@ mod tests {
assert_eq!(
event_rx.try_recv(),
Ok(WorkerControlStreamEvent::Line(
r#"{"v":1,"type":"interview.answer","qid":"q-1","answer":{"kind":"yes"}}"#
r#"{"v":1,"type":"interview.answer","qid":"q-1","answer":{"kind":"yes"},"actor":{"kind":"system","system_kind":"engine"}}"#
.to_string()
))
);
@ -876,7 +890,7 @@ mod tests {
)
.await;
let answer: fabro_interview::Answer = answer_task.await.unwrap();
let answer = answer_task.await.unwrap().answer;
assert_eq!(answer.value, AnswerValue::Interrupted);
assert!(!cancel_token.load(Ordering::SeqCst));
}

View file

@ -446,9 +446,13 @@ fn attach_json_errors_without_prompting_for_human_input() {
[
{
"actor": {
"display": "dev",
"id": "dev",
"kind": "user"
"auth_method": "dev_token",
"identity": {
"issuer": "fabro:dev",
"subject": "dev"
},
"kind": "user",
"login": "dev"
},
"event": "run.created",
"id": "[EVENT_ID]",
@ -566,6 +570,11 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
"subject": {
"auth_method": "dev_token",
"identity": {
"issuer": "fabro:dev",
"subject": "dev"
},
"kind": "user",
"login": "dev"
}
},
@ -678,9 +687,8 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
{
"actor": {
"display": "system:worker",
"id": "worker",
"kind": "system"
"kind": "worker",
"run_id": "[ULID]"
},
"event": "run.starting",
"id": "[EVENT_ID]",
@ -690,9 +698,8 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
{
"actor": {
"display": "system:worker",
"id": "worker",
"kind": "system"
"kind": "worker",
"run_id": "[ULID]"
},
"event": "sandbox.initializing",
"id": "[EVENT_ID]",
@ -704,9 +711,8 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
{
"actor": {
"display": "system:worker",
"id": "worker",
"kind": "system"
"kind": "worker",
"run_id": "[ULID]"
},
"event": "sandbox.ready",
"id": "[EVENT_ID]",
@ -719,9 +725,8 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
{
"actor": {
"display": "system:worker",
"id": "worker",
"kind": "system"
"kind": "worker",
"run_id": "[ULID]"
},
"event": "sandbox.initialized",
"id": "[EVENT_ID]",
@ -734,9 +739,8 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
{
"actor": {
"display": "system:worker",
"id": "worker",
"kind": "system"
"kind": "worker",
"run_id": "[ULID]"
},
"event": "run.started",
"id": "[EVENT_ID]",
@ -749,9 +753,8 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
{
"actor": {
"display": "system:worker",
"id": "worker",
"kind": "system"
"kind": "worker",
"run_id": "[ULID]"
},
"event": "run.running",
"id": "[EVENT_ID]",
@ -761,9 +764,8 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
{
"actor": {
"display": "system:worker",
"id": "worker",
"kind": "system"
"kind": "worker",
"run_id": "[ULID]"
},
"event": "stage.started",
"id": "[EVENT_ID]",
@ -781,9 +783,8 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
{
"actor": {
"display": "system:worker",
"id": "worker",
"kind": "system"
"kind": "worker",
"run_id": "[ULID]"
},
"event": "stage.completed",
"id": "[EVENT_ID]",
@ -814,9 +815,8 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
{
"actor": {
"display": "system:worker",
"id": "worker",
"kind": "system"
"kind": "worker",
"run_id": "[ULID]"
},
"event": "edge.selected",
"id": "[EVENT_ID]",
@ -832,9 +832,8 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
{
"actor": {
"display": "system:worker",
"id": "worker",
"kind": "system"
"kind": "worker",
"run_id": "[ULID]"
},
"event": "checkpoint.completed",
"id": "[EVENT_ID]",
@ -875,9 +874,8 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
{
"actor": {
"display": "system:worker",
"id": "worker",
"kind": "system"
"kind": "worker",
"run_id": "[ULID]"
},
"event": "stage.started",
"id": "[EVENT_ID]",
@ -895,9 +893,8 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
{
"actor": {
"display": "system:worker",
"id": "worker",
"kind": "system"
"kind": "worker",
"run_id": "[ULID]"
},
"event": "interview.started",
"id": "[EVENT_ID]",
@ -926,9 +923,8 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
{
"actor": {
"display": "system:worker",
"id": "worker",
"kind": "system"
"kind": "worker",
"run_id": "[ULID]"
},
"event": "run.blocked",
"id": "[EVENT_ID]",

View file

@ -131,8 +131,8 @@ fn logs_completed_run_reads_store_without_progress_jsonl() {
success: true
exit_code: 0
----- stdout -----
{"actor":{"display":"system:worker","id":"worker","kind":"system"},"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
{"actor":{"display":"system:worker","id":"worker","kind":"system"},"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
{"actor":{"kind":"worker","run_id":"[ULID]"},"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
{"actor":{"kind":"worker","run_id":"[ULID]"},"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
----- stderr -----
"#);
}
@ -165,8 +165,8 @@ fn logs_tail_limits_output() {
success: true
exit_code: 0
----- stdout -----
{"actor":{"display":"system:worker","id":"worker","kind":"system"},"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
{"actor":{"display":"system:worker","id":"worker","kind":"system"},"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
{"actor":{"kind":"worker","run_id":"[ULID]"},"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
{"actor":{"kind":"worker","run_id":"[ULID]"},"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
----- stderr -----
"#);
}

View file

@ -844,9 +844,8 @@ fn dry_run_persists_event_history_in_store() {
fabro_json_snapshot!(context, &live_content, @r#"
{
"actor": {
"display": "system:worker",
"id": "worker",
"kind": "system"
"kind": "worker",
"run_id": "[ULID]"
},
"event": "sandbox.cleanup.completed",
"id": "[EVENT_ID]",

View file

@ -1468,9 +1468,8 @@ async fn append_run_event(
"event": event_name,
"properties": properties,
"actor": {
"kind": "system",
"id": "worker",
"display": "system:worker",
"kind": "worker",
"run_id": run_id,
},
});
if let Some(node_id) = node_id {

View file

@ -369,12 +369,11 @@ async fn github_only_server_dispatched_worker_succeeds_without_worker_auth_store
let events = wait_for_completed_events(&server.api_base_url, &run_id, &access_token).await;
assert!(events.iter().any(|event| {
event
.event
.actor
.as_ref()
.and_then(|actor| actor.display.as_deref())
== Some("system:worker")
matches!(
event.event.actor.as_ref(),
Some(fabro_api::types::Principal::Worker { run_id: actor_run_id })
if actor_run_id.to_string() == run_id
)
}));
assert!(!server.worker_home.join("auth.json").exists());
assert!(!server.worker_home.join("auth.lock").exists());

View file

@ -1,5 +1,5 @@
use chrono::{Duration as ChronoDuration, Utc};
use fabro_types::RunAuthMethod;
use fabro_types::AuthMethod;
use hkdf::Hkdf;
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use sha2::Sha256;
@ -50,7 +50,7 @@ struct TestJwtClaims {
email: String,
avatar_url: String,
user_url: String,
auth_method: RunAuthMethod,
auth_method: AuthMethod,
}
pub(crate) fn issue_test_github_jwt(issuer: &str) -> String {
@ -103,7 +103,7 @@ fn issue_github_jwt(
email: subject.email,
avatar_url: subject.avatar_url,
user_url: subject.user_url,
auth_method: RunAuthMethod::Github,
auth_method: AuthMethod::Github,
};
jsonwebtoken::encode(
&Header::new(Algorithm::HS256),

View file

@ -1,16 +1,30 @@
use async_trait::async_trait;
use fabro_types::QuestionType;
use fabro_types::{Principal, QuestionType, SystemActorKind};
use crate::{Answer, AnswerValue, Interviewer, Question};
use crate::{Answer, AnswerSubmission, AnswerValue, Interviewer, Question};
/// Always approves: YES for yes/no, first option for multiple choice,
/// "auto-approved" for freeform.
pub struct AutoApproveInterviewer;
pub struct AutoApproveInterviewer {
actor: Principal,
}
impl AutoApproveInterviewer {
#[must_use]
pub fn new(actor: Principal) -> Self {
Self { actor }
}
#[must_use]
pub fn engine() -> Self {
Self::new(Principal::system(SystemActorKind::Engine))
}
}
#[async_trait]
impl Interviewer for AutoApproveInterviewer {
async fn ask(&self, question: Question) -> Answer {
match question.question_type {
async fn ask(&self, question: Question) -> AnswerSubmission {
let answer = match question.question_type {
QuestionType::YesNo | QuestionType::Confirmation => Answer::yes(),
QuestionType::MultipleChoice | QuestionType::MultiSelect => {
question.options.first().map_or_else(
@ -23,7 +37,8 @@ impl Interviewer for AutoApproveInterviewer {
)
}
QuestionType::Freeform => Answer::text("auto-approved"),
}
};
AnswerSubmission::new(answer, self.actor.clone())
}
}
@ -35,23 +50,23 @@ mod tests {
#[tokio::test]
async fn yes_no_returns_yes() {
let interviewer = AutoApproveInterviewer;
let interviewer = AutoApproveInterviewer::engine();
let q = Question::new("Approve?", QuestionType::YesNo);
let answer = interviewer.ask(q).await;
let answer = interviewer.ask(q).await.answer;
assert_eq!(answer.value, AnswerValue::Yes);
}
#[tokio::test]
async fn confirmation_returns_yes() {
let interviewer = AutoApproveInterviewer;
let interviewer = AutoApproveInterviewer::engine();
let q = Question::new("Confirm?", QuestionType::Confirmation);
let answer = interviewer.ask(q).await;
let answer = interviewer.ask(q).await.answer;
assert_eq!(answer.value, AnswerValue::Yes);
}
#[tokio::test]
async fn multiple_choice_returns_first_option() {
let interviewer = AutoApproveInterviewer;
let interviewer = AutoApproveInterviewer::engine();
let mut q = Question::new("Choose:", QuestionType::MultipleChoice);
q.options = vec![
InterviewOption {
@ -63,7 +78,7 @@ mod tests {
label: "Beta".to_string(),
},
];
let answer = interviewer.ask(q).await;
let answer = interviewer.ask(q).await.answer;
assert_eq!(answer.value, AnswerValue::Selected("A".to_string()));
assert_eq!(
answer.selected_option,
@ -76,17 +91,17 @@ mod tests {
#[tokio::test]
async fn multiple_choice_no_options_returns_auto_approved() {
let interviewer = AutoApproveInterviewer;
let interviewer = AutoApproveInterviewer::engine();
let q = Question::new("Choose:", QuestionType::MultipleChoice);
let answer = interviewer.ask(q).await;
let answer = interviewer.ask(q).await.answer;
assert_eq!(answer.value, AnswerValue::Text("auto-approved".to_string()));
}
#[tokio::test]
async fn freeform_returns_auto_approved() {
let interviewer = AutoApproveInterviewer;
let interviewer = AutoApproveInterviewer::engine();
let q = Question::new("Enter text:", QuestionType::Freeform);
let answer = interviewer.ask(q).await;
let answer = interviewer.ask(q).await.answer;
assert_eq!(answer.value, AnswerValue::Text("auto-approved".to_string()));
assert_eq!(answer.text, Some("auto-approved".to_string()));
}

View file

@ -1,24 +1,34 @@
use async_trait::async_trait;
use fabro_types::{Principal, SystemActorKind};
use crate::{Answer, Interviewer, Question};
use crate::{Answer, AnswerSubmission, Interviewer, Question};
/// Delegates question answering to a provided callback function.
pub struct CallbackInterviewer {
callback: Box<dyn Fn(Question) -> Answer + Send + Sync>,
actor: Principal,
}
impl CallbackInterviewer {
pub fn new(callback: impl Fn(Question) -> Answer + Send + Sync + 'static) -> Self {
Self::with_actor(Principal::system(SystemActorKind::Engine), callback)
}
pub fn with_actor(
actor: Principal,
callback: impl Fn(Question) -> Answer + Send + Sync + 'static,
) -> Self {
Self {
callback: Box::new(callback),
actor,
}
}
}
#[async_trait]
impl Interviewer for CallbackInterviewer {
async fn ask(&self, question: Question) -> Answer {
(self.callback)(question)
async fn ask(&self, question: Question) -> AnswerSubmission {
AnswerSubmission::new((self.callback)(question), self.actor.clone())
}
}
@ -40,11 +50,11 @@ mod tests {
});
let yes_q = Question::new("approve?", QuestionType::YesNo);
let answer = interviewer.ask(yes_q).await;
let answer = interviewer.ask(yes_q).await.answer;
assert_eq!(answer.value, AnswerValue::Yes);
let no_q = Question::new("choose:", QuestionType::MultipleChoice);
let answer = interviewer.ask(no_q).await;
let answer = interviewer.ask(no_q).await.answer;
assert_eq!(answer.value, AnswerValue::No);
}
@ -52,7 +62,7 @@ mod tests {
async fn callback_receives_question_text() {
let interviewer = CallbackInterviewer::new(|q| Answer::text(q.text));
let q = Question::new("hello world", QuestionType::Freeform);
let answer = interviewer.ask(q).await;
let answer = interviewer.ask(q).await.answer;
assert_eq!(answer.text, Some("hello world".to_string()));
}
}

View file

@ -3,12 +3,12 @@ use std::io::IsTerminal;
use async_trait::async_trait;
use dialoguer::console::Term;
use dialoguer::theme::ColorfulTheme;
use fabro_types::{InterviewOption, QuestionType};
use fabro_types::{InterviewOption, Principal, QuestionType};
use fabro_util::terminal::Styles;
use tokio::io::{self, AsyncBufReadExt, BufReader};
use tokio::task;
use crate::{Answer, AnswerValue, Interviewer, Question};
use crate::{Answer, AnswerSubmission, AnswerValue, Interviewer, Question};
enum PromptRead {
Line(String),
@ -20,12 +20,13 @@ enum PromptRead {
/// 6.4.
pub struct ConsoleInterviewer {
styles: &'static Styles,
actor: Principal,
}
impl ConsoleInterviewer {
#[must_use]
pub fn new(styles: &'static Styles) -> Self {
Self { styles }
pub fn new(styles: &'static Styles, actor: Principal) -> Self {
Self { styles, actor }
}
}
@ -222,7 +223,7 @@ impl Interviewer for ConsoleInterviewer {
clippy::print_stderr,
reason = "Interactive questions and options belong on stderr, not captured stdout."
)]
async fn ask(&self, question: Question) -> Answer {
async fn ask(&self, question: Question) -> AnswerSubmission {
// If stdin is a TTY, use dialoguer for interactive arrow-key navigation.
// Otherwise, fall back to the line-based reader for piped input.
#[expect(
@ -237,7 +238,7 @@ impl Interviewer for ConsoleInterviewer {
eprint!("{rendered}");
}
let q = question;
return task::spawn_blocking(move || match q.question_type {
let answer = task::spawn_blocking(move || match q.question_type {
QuestionType::MultipleChoice => ask_select_interactive(&q),
QuestionType::MultiSelect => ask_multi_select_interactive(&q),
QuestionType::YesNo | QuestionType::Confirmation => ask_confirm_interactive(&q),
@ -245,13 +246,14 @@ impl Interviewer for ConsoleInterviewer {
})
.await
.unwrap_or_else(|_| Answer::interrupted());
return AnswerSubmission::new(answer, self.actor.clone());
}
// Non-TTY fallback: line-based stdin reading
let s = self.styles;
eprintln!("{} {}", s.bold_cyan.apply_to("?"), question.text);
match question.question_type {
let answer = match question.question_type {
QuestionType::MultipleChoice | QuestionType::MultiSelect => {
for (i, opt) in question.options.iter().enumerate() {
eprintln!(
@ -272,7 +274,8 @@ impl Interviewer for ConsoleInterviewer {
parse_non_tty_confirm_response(read_line("[Y/N]: ").await)
}
QuestionType::Freeform => parse_non_tty_freeform_response(read_line("> ").await),
}
};
AnswerSubmission::new(answer, self.actor.clone())
}
#[allow(

View file

@ -3,7 +3,7 @@ use std::collections::HashMap;
use async_trait::async_trait;
use tokio::sync::{Mutex, oneshot};
use crate::{Answer, Interviewer, Question};
use crate::{Answer, AnswerSubmission, Interviewer, Question};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubmitError {
@ -12,9 +12,9 @@ pub enum SubmitError {
#[derive(Default)]
struct ControlInterviewerState {
pending: HashMap<String, oneshot::Sender<Answer>>,
queued: HashMap<String, Answer>,
terminal_answer: Option<Answer>,
pending: HashMap<String, oneshot::Sender<AnswerSubmission>>,
queued: HashMap<String, AnswerSubmission>,
terminal_submission: Option<AnswerSubmission>,
}
#[derive(Default)]
@ -28,16 +28,16 @@ impl ControlInterviewer {
Self::default()
}
async fn register(&self, question_id: String) -> oneshot::Receiver<Answer> {
async fn register(&self, question_id: String) -> oneshot::Receiver<AnswerSubmission> {
let mut state = self.state.lock().await;
if let Some(answer) = state.terminal_answer.clone() {
if let Some(submission) = state.terminal_submission.clone() {
let (tx, rx) = oneshot::channel();
let _ = tx.send(answer);
let _ = tx.send(submission);
return rx;
}
if let Some(answer) = state.queued.remove(&question_id) {
if let Some(submission) = state.queued.remove(&question_id) {
let (tx, rx) = oneshot::channel();
let _ = tx.send(answer);
let _ = tx.send(submission);
return rx;
}
@ -46,10 +46,14 @@ impl ControlInterviewer {
rx
}
pub async fn submit(&self, question_id: &str, answer: Answer) -> Result<(), SubmitError> {
pub async fn submit(
&self,
question_id: &str,
submission: AnswerSubmission,
) -> Result<(), SubmitError> {
let pending_sender = {
let mut state = self.state.lock().await;
if state.terminal_answer.is_some() {
if state.terminal_submission.is_some() {
return Err(SubmitError::AlreadyResolved);
}
if let Some(sender) = state.pending.remove(question_id) {
@ -57,31 +61,39 @@ impl ControlInterviewer {
} else if state.queued.contains_key(question_id) {
return Err(SubmitError::AlreadyResolved);
} else {
state.queued.insert(question_id.to_string(), answer);
state.queued.insert(question_id.to_string(), submission);
return Ok(());
}
};
match pending_sender {
Some(sender) => sender
.send(answer)
.send(submission)
.map_err(|_| SubmitError::AlreadyResolved),
None => Err(SubmitError::AlreadyResolved),
}
}
pub async fn interrupt_all(&self) {
self.resolve_all(Answer::interrupted()).await;
self.resolve_all(AnswerSubmission::system(
Answer::interrupted(),
fabro_types::SystemActorKind::Engine,
))
.await;
}
pub async fn cancel_all(&self) {
self.resolve_all(Answer::cancelled()).await;
self.resolve_all(AnswerSubmission::system(
Answer::cancelled(),
fabro_types::SystemActorKind::Engine,
))
.await;
}
async fn resolve_all(&self, answer: Answer) {
async fn resolve_all(&self, submission: AnswerSubmission) {
let (pending, queued) = {
let mut state = self.state.lock().await;
state.terminal_answer = Some(answer.clone());
state.terminal_submission = Some(submission.clone());
let pending = state
.pending
.drain()
@ -93,7 +105,7 @@ impl ControlInterviewer {
};
for sender in pending {
let _ = sender.send(answer.clone());
let _ = sender.send(submission.clone());
}
if queued > 0 {
@ -107,11 +119,14 @@ impl ControlInterviewer {
#[async_trait]
impl Interviewer for ControlInterviewer {
async fn ask(&self, question: Question) -> Answer {
async fn ask(&self, question: Question) -> AnswerSubmission {
let receiver = self.register(question.id.clone()).await;
match receiver.await {
Ok(answer) => answer,
Err(_) => Answer::interrupted(),
Ok(submission) => submission,
Err(_) => AnswerSubmission::system(
Answer::interrupted(),
fabro_types::SystemActorKind::Engine,
),
}
}
@ -130,10 +145,14 @@ mod tests {
use super::*;
use crate::AnswerValue;
fn submission(answer: Answer) -> AnswerSubmission {
AnswerSubmission::system(answer, fabro_types::SystemActorKind::Engine)
}
#[tokio::test]
async fn submit_before_ask_buffers_answer() {
let interviewer = ControlInterviewer::new();
let result = interviewer.submit("q-1", Answer::yes()).await;
let result = interviewer.submit("q-1", submission(Answer::yes())).await;
assert_eq!(result, Ok(()));
}
@ -146,30 +165,36 @@ mod tests {
let ask_interviewer = Arc::clone(&interviewer);
let ask = tokio::spawn(async move { ask_interviewer.ask(question).await });
let submit_result = interviewer.submit("q-1", Answer::yes()).await;
let submit_result = interviewer.submit("q-1", submission(Answer::yes())).await;
assert_eq!(submit_result, Ok(()));
let answer = ask.await.unwrap();
let answer = ask.await.unwrap().answer;
assert_eq!(answer.value, AnswerValue::Yes);
}
#[tokio::test]
async fn submit_before_register_buffers_answer() {
let interviewer = Arc::new(ControlInterviewer::new());
assert_eq!(interviewer.submit("q-1", Answer::no()).await, Ok(()));
assert_eq!(
interviewer.submit("q-1", submission(Answer::no())).await,
Ok(())
);
let mut question = Question::new("approve?", QuestionType::YesNo);
question.id = "q-1".to_string();
let answer = interviewer.ask(question).await;
let answer = interviewer.ask(question).await.answer;
assert_eq!(answer.value, AnswerValue::No);
}
#[tokio::test]
async fn duplicate_buffered_answer_is_rejected() {
let interviewer = ControlInterviewer::new();
assert_eq!(interviewer.submit("q-1", Answer::yes()).await, Ok(()));
assert_eq!(
interviewer.submit("q-1", Answer::no()).await,
interviewer.submit("q-1", submission(Answer::yes())).await,
Ok(())
);
assert_eq!(
interviewer.submit("q-1", submission(Answer::no())).await,
Err(SubmitError::AlreadyResolved)
);
}
@ -186,7 +211,7 @@ mod tests {
interviewer.interrupt_all().await;
let answer = ask.await.unwrap();
let answer = ask.await.unwrap().answer;
assert_eq!(answer.value, AnswerValue::Interrupted);
}
@ -198,7 +223,7 @@ mod tests {
let mut question = Question::new("approve?", QuestionType::YesNo);
question.id = "q-1".to_string();
let answer = interviewer.ask(question).await;
let answer = interviewer.ask(question).await.answer;
assert_eq!(answer.value, AnswerValue::Interrupted);
}
@ -214,7 +239,7 @@ mod tests {
interviewer.cancel_all().await;
let answer = ask.await.unwrap();
let answer = ask.await.unwrap().answer;
assert_eq!(answer.value, AnswerValue::Cancelled);
}
@ -226,7 +251,7 @@ mod tests {
let mut question = Question::new("approve?", QuestionType::YesNo);
question.id = "q-1".to_string();
let answer = interviewer.ask(question).await;
let answer = interviewer.ask(question).await.answer;
assert_eq!(answer.value, AnswerValue::Cancelled);
}
}

View file

@ -1,6 +1,7 @@
use fabro_types::Principal;
use serde::{Deserialize, Serialize};
use crate::{Answer, AnswerValue};
use crate::{Answer, AnswerSubmission, AnswerValue};
pub const WORKER_CONTROL_PROTOCOL_VERSION: u8 = 1;
@ -13,12 +14,13 @@ pub struct WorkerControlEnvelope {
impl WorkerControlEnvelope {
#[must_use]
pub fn interview_answer(qid: impl Into<String>, answer: Answer) -> Self {
pub fn interview_answer(qid: impl Into<String>, submission: AnswerSubmission) -> Self {
Self {
v: WORKER_CONTROL_PROTOCOL_VERSION,
message: WorkerControlMessage::InterviewAnswer {
qid: qid.into(),
answer: answer.into(),
answer: submission.answer.into(),
actor: submission.actor,
},
}
}
@ -39,6 +41,7 @@ pub enum WorkerControlMessage {
InterviewAnswer {
qid: String,
answer: WorkerControlAnswer,
actor: Principal,
},
#[serde(rename = "run.cancel")]
RunCancel,
@ -100,11 +103,17 @@ mod tests {
#[test]
fn interview_answer_round_trips_through_json() {
let envelope = WorkerControlEnvelope::interview_answer("q-1", Answer::text("ship it"));
let envelope = WorkerControlEnvelope::interview_answer(
"q-1",
AnswerSubmission::system(
Answer::text("ship it"),
fabro_types::SystemActorKind::Engine,
),
);
let json = serde_json::to_string(&envelope).unwrap();
assert_eq!(
json,
r#"{"v":1,"type":"interview.answer","qid":"q-1","answer":{"kind":"text","text":"ship it"}}"#
r#"{"v":1,"type":"interview.answer","qid":"q-1","answer":{"kind":"text","text":"ship it"},"actor":{"kind":"system","system_kind":"engine"}}"#
);
let parsed: WorkerControlEnvelope = serde_json::from_str(&json).unwrap();

View file

@ -10,7 +10,7 @@ mod replay;
use std::collections::HashMap;
use async_trait::async_trait;
use fabro_types::{InterviewOption, QuestionType};
use fabro_types::{InterviewOption, Principal, QuestionType, SystemActorKind};
use serde::{Deserialize, Serialize};
use tokio::time;
@ -152,10 +152,35 @@ impl Answer {
}
}
/// An answer plus the principal that supplied it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnswerSubmission {
pub answer: Answer,
pub actor: Principal,
}
impl AnswerSubmission {
#[must_use]
pub fn new(answer: Answer, actor: Principal) -> Self {
Self { answer, actor }
}
#[must_use]
pub fn system(answer: Answer, system_kind: SystemActorKind) -> Self {
Self {
answer,
actor: Principal::system(system_kind),
}
}
}
/// Apply timeout enforcement to an interviewer ask call.
/// Per spec 6.5: if `timeout_seconds` is set, returns default answer or
/// `Answer::timeout()`.
pub async fn ask_with_timeout(interviewer: &dyn Interviewer, question: Question) -> Answer {
pub async fn ask_with_timeout(
interviewer: &dyn Interviewer,
question: Question,
) -> AnswerSubmission {
let timeout_secs = question.timeout_seconds;
let default_answer = question.default.clone();
@ -163,7 +188,10 @@ pub async fn ask_with_timeout(interviewer: &dyn Interviewer, question: Question)
let duration = std::time::Duration::from_secs_f64(secs);
match time::timeout(duration, interviewer.ask(question)).await {
Ok(answer) => answer,
Err(_elapsed) => default_answer.unwrap_or_else(Answer::timeout),
Err(_elapsed) => AnswerSubmission::system(
default_answer.unwrap_or_else(Answer::timeout),
SystemActorKind::Timeout,
),
}
} else {
interviewer.ask(question).await
@ -173,9 +201,9 @@ pub async fn ask_with_timeout(interviewer: &dyn Interviewer, question: Question)
/// The interviewer trait for human-in-the-loop interactions.
#[async_trait]
pub trait Interviewer: Send + Sync {
async fn ask(&self, question: Question) -> Answer;
async fn ask(&self, question: Question) -> AnswerSubmission;
async fn ask_multiple(&self, questions: Vec<Question>) -> Vec<Answer> {
async fn ask_multiple(&self, questions: Vec<Question>) -> Vec<AnswerSubmission> {
let mut answers = Vec::with_capacity(questions.len());
for q in questions {
answers.push(self.ask(q).await);
@ -328,9 +356,9 @@ mod tests {
#[async_trait]
impl Interviewer for SlowInterviewer {
async fn ask(&self, _question: Question) -> Answer {
async fn ask(&self, _question: Question) -> AnswerSubmission {
time::sleep(std::time::Duration::from_mins(1)).await;
Answer::yes()
AnswerSubmission::system(Answer::yes(), SystemActorKind::Engine)
}
}
@ -340,7 +368,7 @@ mod tests {
let mut q = Question::new("approve?", QuestionType::YesNo);
q.timeout_seconds = Some(0.01);
let answer = ask_with_timeout(&interviewer, q).await;
let answer = ask_with_timeout(&interviewer, q).await.answer;
assert_eq!(answer.value, AnswerValue::Timeout);
}
@ -351,16 +379,16 @@ mod tests {
q.timeout_seconds = Some(0.01);
q.default = Some(Answer::no());
let answer = ask_with_timeout(&interviewer, q).await;
let answer = ask_with_timeout(&interviewer, q).await.answer;
assert_eq!(answer.value, AnswerValue::No);
}
#[tokio::test]
async fn ask_with_timeout_no_timeout_returns_normally() {
let interviewer = AutoApproveInterviewer;
let interviewer = AutoApproveInterviewer::engine();
let q = Question::new("approve?", QuestionType::YesNo);
let answer = ask_with_timeout(&interviewer, q).await;
let answer = ask_with_timeout(&interviewer, q).await.answer;
assert_eq!(answer.value, AnswerValue::Yes);
}
@ -375,9 +403,15 @@ mod tests {
let ask = tokio::spawn(async move { ask_interviewer.ask(question).await });
time::sleep(std::time::Duration::from_millis(10)).await;
interviewer.submit("q-1", Answer::yes()).await.unwrap();
interviewer
.submit(
"q-1",
AnswerSubmission::system(Answer::yes(), SystemActorKind::Engine),
)
.await
.unwrap();
let answer = ask.await.unwrap();
let answer = ask.await.unwrap().answer;
assert_eq!(answer.value, AnswerValue::Yes);
}
}

View file

@ -2,28 +2,39 @@ use std::collections::VecDeque;
use std::sync::Mutex;
use async_trait::async_trait;
use fabro_types::{Principal, SystemActorKind};
use crate::{Answer, Interviewer, Question};
use crate::{Answer, AnswerSubmission, Interviewer, Question};
/// Reads answers from a pre-filled queue. Returns Interrupted when empty.
pub struct QueueInterviewer {
answers: Mutex<VecDeque<Answer>>,
actor: Principal,
}
impl QueueInterviewer {
#[must_use]
pub const fn new(answers: VecDeque<Answer>) -> Self {
pub fn new(answers: VecDeque<Answer>) -> Self {
Self::with_actor(answers, Principal::system(SystemActorKind::Engine))
}
#[must_use]
pub fn with_actor(answers: VecDeque<Answer>, actor: Principal) -> Self {
Self {
answers: Mutex::new(answers),
actor,
}
}
}
#[async_trait]
impl Interviewer for QueueInterviewer {
async fn ask(&self, _question: Question) -> Answer {
async fn ask(&self, _question: Question) -> AnswerSubmission {
let mut queue = self.answers.lock().expect("queue lock poisoned");
queue.pop_front().unwrap_or_else(Answer::interrupted)
AnswerSubmission::new(
queue.pop_front().unwrap_or_else(Answer::interrupted),
self.actor.clone(),
)
}
}
@ -40,10 +51,10 @@ mod tests {
let interviewer = QueueInterviewer::new(answers);
let q = Question::new("q1", QuestionType::YesNo);
let a1 = interviewer.ask(q.clone()).await;
let a1 = interviewer.ask(q.clone()).await.answer;
assert_eq!(a1.value, AnswerValue::Yes);
let a2 = interviewer.ask(q).await;
let a2 = interviewer.ask(q).await.answer;
assert_eq!(a2.value, AnswerValue::No);
}
@ -51,7 +62,7 @@ mod tests {
async fn returns_interrupted_when_empty() {
let interviewer = QueueInterviewer::new(VecDeque::new());
let q = Question::new("q", QuestionType::YesNo);
let answer = interviewer.ask(q).await;
let answer = interviewer.ask(q).await.answer;
assert_eq!(answer.value, AnswerValue::Interrupted);
}
@ -62,7 +73,7 @@ mod tests {
let q = Question::new("q", QuestionType::YesNo);
let _ = interviewer.ask(q.clone()).await;
let answer = interviewer.ask(q).await;
let answer = interviewer.ask(q).await.answer;
assert_eq!(answer.value, AnswerValue::Interrupted);
}
}

View file

@ -3,12 +3,12 @@ use std::sync::Mutex;
use async_trait::async_trait;
use crate::{Answer, Interviewer, Question};
use crate::{AnswerSubmission, Interviewer, Question};
/// Wraps another interviewer and records all question-answer pairs.
pub struct RecordingInterviewer {
inner: Box<dyn Interviewer>,
recordings: Mutex<Vec<(Question, Answer)>>,
inner: Box<dyn Interviewer>,
submissions: Mutex<Vec<(Question, AnswerSubmission)>>,
}
impl RecordingInterviewer {
@ -16,15 +16,15 @@ impl RecordingInterviewer {
pub fn new(inner: Box<dyn Interviewer>) -> Self {
Self {
inner,
recordings: Mutex::new(Vec::new()),
submissions: Mutex::new(Vec::new()),
}
}
/// # Panics
/// Panics if the internal mutex is poisoned.
#[must_use]
pub fn recordings(&self) -> Vec<(Question, Answer)> {
self.recordings
pub fn recordings(&self) -> Vec<(Question, AnswerSubmission)> {
self.submissions
.lock()
.expect("recordings lock poisoned")
.clone()
@ -43,7 +43,7 @@ impl RecordingInterviewer {
///
/// # Errors
/// Returns an error if deserialization fails.
pub fn from_json(json: &str) -> std::io::Result<Vec<(Question, Answer)>> {
pub fn from_json(json: &str) -> std::io::Result<Vec<(Question, AnswerSubmission)>> {
serde_json::from_str(json).map_err(std::io::Error::other)
}
@ -74,7 +74,7 @@ impl RecordingInterviewer {
clippy::disallowed_methods,
reason = "sync helper for test-mode interview recording storage; not on a Tokio path"
)]
pub fn load_from_file(path: &Path) -> std::io::Result<Vec<(Question, Answer)>> {
pub fn load_from_file(path: &Path) -> std::io::Result<Vec<(Question, AnswerSubmission)>> {
let json = std::fs::read_to_string(path).map_err(|err| {
std::io::Error::new(
err.kind(),
@ -87,13 +87,13 @@ impl RecordingInterviewer {
#[async_trait]
impl Interviewer for RecordingInterviewer {
async fn ask(&self, question: Question) -> Answer {
let answer = self.inner.ask(question.clone()).await;
self.recordings
async fn ask(&self, question: Question) -> AnswerSubmission {
let submission = self.inner.ask(question.clone()).await;
self.submissions
.lock()
.expect("recordings lock poisoned")
.push((question, answer.clone()));
answer
.push((question, submission.clone()));
submission
}
}
@ -106,16 +106,16 @@ mod tests {
#[tokio::test]
async fn records_question_answer_pairs() {
let inner = Box::new(AutoApproveInterviewer);
let inner = Box::new(AutoApproveInterviewer::engine());
let recorder = RecordingInterviewer::new(inner);
let q1 = Question::new("approve?", QuestionType::YesNo);
let q2 = Question::new("confirm?", QuestionType::Confirmation);
let a1 = recorder.ask(q1).await;
let a1 = recorder.ask(q1).await.answer;
assert_eq!(a1.value, AnswerValue::Yes);
let a2 = recorder.ask(q2).await;
let a2 = recorder.ask(q2).await.answer;
assert_eq!(a2.value, AnswerValue::Yes);
let recs = recorder.recordings();
@ -126,24 +126,24 @@ mod tests {
#[tokio::test]
async fn delegates_to_inner() {
let inner = Box::new(AutoApproveInterviewer);
let inner = Box::new(AutoApproveInterviewer::engine());
let recorder = RecordingInterviewer::new(inner);
let q = Question::new("text input", QuestionType::Freeform);
let answer = recorder.ask(q).await;
let answer = recorder.ask(q).await.answer;
assert_eq!(answer.value, AnswerValue::Text("auto-approved".to_string()));
}
#[tokio::test]
async fn recordings_empty_initially() {
let inner = Box::new(AutoApproveInterviewer);
let inner = Box::new(AutoApproveInterviewer::engine());
let recorder = RecordingInterviewer::new(inner);
assert!(recorder.recordings().is_empty());
}
#[tokio::test]
async fn to_json_serializes_recordings() {
let inner = Box::new(AutoApproveInterviewer);
let inner = Box::new(AutoApproveInterviewer::engine());
let recorder = RecordingInterviewer::new(inner);
let q = Question::new("approve?", QuestionType::YesNo);
@ -159,19 +159,22 @@ mod tests {
let json = r#"[
[
{"text":"approve?","question_type":"yes_no","options":[],"allow_freeform":false,"default":null,"timeout_seconds":null,"stage":"","metadata":{}},
{"value":"Yes","selected_option":null,"text":null}
{
"answer":{"value":"Yes","selected_option":null,"text":null},
"actor":{"kind":"system","system_kind":"engine"}
}
]
]"#;
let recordings = RecordingInterviewer::from_json(json).unwrap();
assert_eq!(recordings.len(), 1);
assert_eq!(recordings[0].0.text, "approve?");
assert_eq!(recordings[0].1.value, AnswerValue::Yes);
assert_eq!(recordings[0].1.answer.value, AnswerValue::Yes);
}
#[tokio::test]
async fn save_to_file_and_load_from_file() {
let inner = Box::new(AutoApproveInterviewer);
let inner = Box::new(AutoApproveInterviewer::engine());
let recorder = RecordingInterviewer::new(inner);
let q = Question::new("approve?", QuestionType::YesNo);
@ -185,12 +188,12 @@ mod tests {
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].0.text, "approve?");
assert_eq!(loaded[0].1.value, AnswerValue::Yes);
assert_eq!(loaded[0].1.answer.value, AnswerValue::Yes);
}
#[tokio::test]
async fn round_trip_serialize_deserialize() {
let inner = Box::new(AutoApproveInterviewer);
let inner = Box::new(AutoApproveInterviewer::engine());
let recorder = RecordingInterviewer::new(inner);
let q1 = Question::new("approve?", QuestionType::YesNo);
@ -204,9 +207,9 @@ mod tests {
assert_eq!(restored.len(), 2);
assert_eq!(restored[0].0.text, "approve?");
assert_eq!(restored[0].0.question_type, QuestionType::YesNo);
assert_eq!(restored[0].1.value, AnswerValue::Yes);
assert_eq!(restored[0].1.answer.value, AnswerValue::Yes);
assert_eq!(restored[1].0.text, "confirm?");
assert_eq!(restored[1].0.question_type, QuestionType::Confirmation);
assert_eq!(restored[1].1.value, AnswerValue::Yes);
assert_eq!(restored[1].1.answer.value, AnswerValue::Yes);
}
}

View file

@ -1,13 +1,14 @@
use std::sync::Mutex;
use async_trait::async_trait;
use fabro_types::{Principal, SystemActorKind};
use crate::{Answer, Interviewer, Question};
use crate::{Answer, AnswerSubmission, Interviewer, Question};
/// Replays recorded answers in sequence. When recordings are exhausted,
/// returns `Answer::interrupted()`.
pub struct ReplayInterviewer {
answers: Mutex<Vec<Answer>>,
submissions: Mutex<Vec<AnswerSubmission>>,
}
impl ReplayInterviewer {
@ -15,21 +16,28 @@ impl ReplayInterviewer {
/// question-answer pairs. Only the answers are retained for replay.
#[must_use]
pub fn new(recordings: Vec<(Question, Answer)>) -> Self {
let answers: Vec<Answer> = recordings.into_iter().map(|(_, a)| a).collect();
let actor = Principal::system(SystemActorKind::Engine);
let submissions: Vec<AnswerSubmission> = recordings
.into_iter()
.map(|(_, answer)| AnswerSubmission::new(answer, actor.clone()))
.collect();
Self {
answers: Mutex::new(answers),
submissions: Mutex::new(submissions),
}
}
}
#[async_trait]
impl Interviewer for ReplayInterviewer {
async fn ask(&self, _question: Question) -> Answer {
let mut answers = self.answers.lock().expect("answers lock poisoned");
if answers.is_empty() {
Answer::interrupted()
async fn ask(&self, _question: Question) -> AnswerSubmission {
let mut submissions = self.submissions.lock().expect("answers lock poisoned");
if submissions.is_empty() {
AnswerSubmission::new(
Answer::interrupted(),
Principal::system(SystemActorKind::Engine),
)
} else {
answers.remove(0)
submissions.remove(0)
}
}
}
@ -58,12 +66,14 @@ mod tests {
let a1 = replayer
.ask(Question::new("anything", QuestionType::YesNo))
.await;
.await
.answer;
assert_eq!(a1.value, AnswerValue::Yes);
let a2 = replayer
.ask(Question::new("anything", QuestionType::Freeform))
.await;
.await
.answer;
assert_eq!(a2.value, AnswerValue::Text("Alice".to_string()));
}
@ -78,17 +88,20 @@ mod tests {
let a1 = replayer
.ask(Question::new("first", QuestionType::YesNo))
.await;
.await
.answer;
assert_eq!(a1.value, AnswerValue::Yes);
let a2 = replayer
.ask(Question::new("second", QuestionType::YesNo))
.await;
.await
.answer;
assert_eq!(a2.value, AnswerValue::Interrupted);
let a3 = replayer
.ask(Question::new("third", QuestionType::YesNo))
.await;
.await
.answer;
assert_eq!(a3.value, AnswerValue::Interrupted);
}
}

View file

@ -16,7 +16,7 @@ use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use cookie::time::Duration;
use cookie::{Cookie, CookieJar, Key, SameSite};
use fabro_types::RunAuthMethod;
use fabro_types::AuthMethod;
use fabro_types::settings::ServerAuthMethod;
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
use rand::TryRngCore;
@ -475,7 +475,7 @@ async fn token(
email: entry.email.clone(),
avatar_url: String::new(),
user_url: String::new(),
auth_method: RunAuthMethod::Github,
auth_method: AuthMethod::Github,
},
chrono::Duration::minutes(ACCESS_TOKEN_TTL_MINUTES),
);
@ -620,7 +620,7 @@ async fn refresh(
email: old.email.clone(),
avatar_url: String::new(),
user_url: String::new(),
auth_method: RunAuthMethod::Github,
auth_method: AuthMethod::Github,
},
chrono::Duration::minutes(ACCESS_TOKEN_TTL_MINUTES),
);
@ -701,7 +701,7 @@ fn github_config(auth_mode: &AuthMode) -> Option<&ConfiguredAuth> {
AuthMode::Enabled(config) if config.methods.contains(&ServerAuthMethod::Github) => {
Some(config)
}
_ => None,
AuthMode::Enabled(_) => None,
}
}
@ -722,9 +722,8 @@ fn session_cookie_secure(state: &AppState) -> bool {
}
fn eligible_session(session: Option<&SessionCookie>) -> Option<&SessionCookie> {
session.filter(|session| {
session.identity.is_some() && session.auth_method == RunAuthMethod::Github
})
session
.filter(|session| session.identity.is_some() && session.auth_method == AuthMethod::Github)
}
fn valid_state_token(state: &str) -> bool {
@ -1133,7 +1132,7 @@ mod tests {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use fabro_config::{RunLayer, ServerSettingsBuilder};
use fabro_types::RunAuthMethod;
use fabro_types::AuthMethod;
use fabro_types::settings::server::ServerAuthMethod;
use serde_json::json;
use sha2::{Digest, Sha256};
@ -1229,7 +1228,7 @@ client_id = "github-client-id"
let session = SessionCookie {
v: 2,
login: "octocat".to_string(),
auth_method: RunAuthMethod::Github,
auth_method: AuthMethod::Github,
identity: Some(
fabro_types::IdpIdentity::new("https://github.com", "12345")
.expect("identity should be valid"),

View file

@ -1,5 +1,5 @@
use chrono::{Duration, Utc};
use fabro_types::{IdpIdentity, RunAuthMethod};
use fabro_types::{AuthMethod, IdpIdentity};
use jsonwebtoken::errors::{Error as JwtDecodeError, ErrorKind};
use jsonwebtoken::{Algorithm, Header, Validation, decode, decode_header, encode};
use serde::{Deserialize, Serialize};
@ -18,7 +18,7 @@ pub(crate) struct JwtSubject {
pub email: String,
pub avatar_url: String,
pub user_url: String,
pub auth_method: RunAuthMethod,
pub auth_method: AuthMethod,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -38,7 +38,7 @@ pub(crate) struct Claims {
pub avatar_url: String,
#[serde(default)]
pub user_url: String,
pub auth_method: RunAuthMethod,
pub auth_method: AuthMethod,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
@ -127,7 +127,7 @@ mod tests {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{Duration, Utc};
use fabro_types::RunAuthMethod;
use fabro_types::AuthMethod;
use jsonwebtoken::{Algorithm, Header, encode};
use serde::Serialize;
use uuid::Uuid;
@ -148,7 +148,7 @@ mod tests {
email: "octocat@example.com".to_string(),
avatar_url: "https://example.com/octocat.png".to_string(),
user_url: "https://github.com/octocat".to_string(),
auth_method: RunAuthMethod::Github,
auth_method: AuthMethod::Github,
}
}
@ -167,7 +167,7 @@ mod tests {
email: "octocat@example.com".to_string(),
avatar_url: "https://example.com/octocat.png".to_string(),
user_url: "https://github.com/octocat".to_string(),
auth_method: RunAuthMethod::Github,
auth_method: AuthMethod::Github,
}
}
@ -202,7 +202,7 @@ mod tests {
assert_eq!(claims.idp_subject, "12345");
assert_eq!(claims.avatar_url, "https://example.com/octocat.png");
assert_eq!(claims.user_url, "https://github.com/octocat");
assert_eq!(claims.auth_method, RunAuthMethod::Github);
assert_eq!(claims.auth_method, AuthMethod::Github);
assert!(Uuid::parse_str(&claims.jti).is_ok());
}
@ -221,7 +221,7 @@ mod tests {
login: String,
name: String,
email: String,
auth_method: RunAuthMethod,
auth_method: AuthMethod,
}
let now = Utc::now().timestamp();
@ -239,7 +239,7 @@ mod tests {
login: "octocat".to_string(),
name: "The Octocat".to_string(),
email: "octocat@example.com".to_string(),
auth_method: RunAuthMethod::Github,
auth_method: AuthMethod::Github,
},
&signing_key().encoding_key(),
);

View file

@ -5,7 +5,7 @@ use axum::http::{HeaderMap, HeaderValue, header};
use axum::middleware::Next;
use axum::response::Response;
use chrono::{Duration, Utc};
use fabro_types::{IdpIdentity, RunAuthMethod};
use fabro_types::{AuthMethod, IdpIdentity};
use fabro_util::dev_token::validate_dev_token_format;
use tracing::trace;
@ -35,23 +35,18 @@ pub(crate) async fn auth_translation_middleware(
mut req: Request,
next: Next,
) -> Response {
let translated = match req
let AuthMode::Enabled(config) = req
.extensions()
.get::<AuthMode>()
.expect("AuthMode extension must be added to the router")
{
AuthMode::Disabled => return next.run(req).await,
AuthMode::Enabled(config) => {
if let Some(auth_header) = req.headers().get(header::AUTHORIZATION) {
auth_header
.to_str()
.ok()
.and_then(|value| value.strip_prefix("Bearer "))
.and_then(|token| translate_bearer_token(token, config))
} else {
translate_session_cookie(req.headers(), state.as_ref(), config)
}
}
.expect("AuthMode extension must be added to the router");
let translated = if let Some(auth_header) = req.headers().get(header::AUTHORIZATION) {
auth_header
.to_str()
.ok()
.and_then(|value| value.strip_prefix("Bearer "))
.and_then(|token| translate_bearer_token(token, config))
} else {
translate_session_cookie(req.headers(), state.as_ref(), config)
};
if let Some(token) = translated {
@ -85,7 +80,7 @@ fn translate_bearer_token(token: &str, config: &ConfiguredAuth) -> Option<String
email: "dev@fabro.local".to_string(),
avatar_url: String::new(),
user_url: String::new(),
auth_method: RunAuthMethod::DevToken,
auth_method: AuthMethod::DevToken,
},
Duration::minutes(ACCESS_TOKEN_TTL_MINUTES),
))
@ -131,7 +126,7 @@ fn session_ttl(session: &SessionCookie) -> Option<Duration> {
}
fn legacy_dev_identity(session: &SessionCookie) -> Option<IdpIdentity> {
(session.auth_method == RunAuthMethod::DevToken).then(dev_identity)
(session.auth_method == AuthMethod::DevToken).then(dev_identity)
}
fn dev_identity() -> IdpIdentity {
@ -155,7 +150,7 @@ mod tests {
use cookie::{Cookie, CookieJar};
use fabro_config::{RunLayer, ServerSettingsBuilder};
use fabro_types::settings::ServerAuthMethod;
use fabro_types::{IdpIdentity, RunAuthMethod};
use fabro_types::{AuthMethod, IdpIdentity};
use serde_json::json;
use tower::ServiceExt;
@ -260,7 +255,7 @@ methods = ["dev-token"]
SessionCookie {
v: 2,
login: "octocat".to_string(),
auth_method: RunAuthMethod::Github,
auth_method: AuthMethod::Github,
identity: Some(IdpIdentity::new("https://github.com", "12345").unwrap()),
name: "The Octocat".to_string(),
email: "octocat@example.com".to_string(),
@ -275,7 +270,7 @@ methods = ["dev-token"]
SessionCookie {
v: 2,
login: "dev".to_string(),
auth_method: RunAuthMethod::DevToken,
auth_method: AuthMethod::DevToken,
identity,
name: "Development User".to_string(),
email: "dev@localhost".to_string(),
@ -297,7 +292,7 @@ methods = ["dev-token"]
email: "octocat@example.com".to_string(),
avatar_url: "https://example.com/octocat.png".to_string(),
user_url: "https://github.com/octocat".to_string(),
auth_method: RunAuthMethod::Github,
auth_method: AuthMethod::Github,
},
chrono::Duration::minutes(10),
)
@ -379,7 +374,7 @@ methods = ["dev-token"]
assert_eq!(claims.email, "dev@fabro.local");
assert_eq!(claims.idp_issuer, "fabro:dev");
assert_eq!(claims.idp_subject, "dev");
assert_eq!(claims.auth_method, RunAuthMethod::DevToken);
assert_eq!(claims.auth_method, AuthMethod::DevToken);
}
#[tokio::test]
@ -410,7 +405,7 @@ methods = ["dev-token"]
assert_eq!(claims.idp_subject, "12345");
assert_eq!(claims.avatar_url, "https://example.com/octocat.png");
assert_eq!(claims.user_url, "https://github.com/octocat");
assert_eq!(claims.auth_method, RunAuthMethod::Github);
assert_eq!(claims.auth_method, AuthMethod::Github);
}
#[tokio::test]
@ -436,7 +431,7 @@ methods = ["dev-token"]
let claims = auth::verify(&signing_key(), "https://fabro.example", token).unwrap();
assert_eq!(claims.idp_issuer, "fabro:dev");
assert_eq!(claims.idp_subject, "dev");
assert_eq!(claims.auth_method, RunAuthMethod::DevToken);
assert_eq!(claims.auth_method, AuthMethod::DevToken);
}
#[tokio::test]
@ -538,7 +533,7 @@ methods = ["dev-token"]
.expect("authorization should be minted despite demo header");
let claims = auth::verify(&signing_key(), "https://fabro.example", token).unwrap();
assert_eq!(claims.login, "octocat");
assert_eq!(claims.auth_method, RunAuthMethod::Github);
assert_eq!(claims.auth_method, AuthMethod::Github);
assert_eq!(json["demo"], "1");
}

View file

@ -21,7 +21,7 @@ use fabro_api::types::{
use serde_json::json;
use crate::error::ApiError;
use crate::jwt_auth::AuthenticatedService;
use crate::principal_middleware::RequiredUser;
use crate::run_selector::{ResolveRunError, resolve_run_by_selector};
use crate::server::{AppState, PaginationParams};
@ -44,7 +44,7 @@ fn paginated_response<T: serde::Serialize>(
// ── Runs ───────────────────────────────────────────────────────────────
pub(crate) async fn list_runs(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> Response {
@ -52,7 +52,7 @@ pub(crate) async fn list_runs(
}
pub(crate) async fn list_board_runs(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> Response {
@ -74,7 +74,7 @@ pub(crate) async fn list_board_runs(
}
pub(crate) async fn create_run_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
) -> Response {
(
@ -85,7 +85,7 @@ pub(crate) async fn create_run_stub(
}
pub(crate) async fn resolve_run(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Query(params): Query<ResolveRunParams>,
) -> Response {
@ -111,7 +111,7 @@ pub(crate) async fn resolve_run(
}
pub(crate) async fn start_run_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
@ -125,7 +125,7 @@ pub(crate) async fn start_run_stub(
}
pub(crate) async fn get_run_stages(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
Query(pagination): Query<PaginationParams>,
@ -134,7 +134,7 @@ pub(crate) async fn get_run_stages(
}
pub(crate) async fn get_stage_turns(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path((_id, _stage_id)): Path<(String, String)>,
Query(pagination): Query<PaginationParams>,
@ -143,7 +143,7 @@ pub(crate) async fn get_stage_turns(
}
pub(crate) async fn list_run_artifacts_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
@ -159,7 +159,7 @@ pub(crate) async fn list_run_artifacts_stub(
/// `_state` parameters are intentionally ignored so demo responses cannot
/// cross-contaminate with real run data (R34).
pub(crate) async fn list_run_files_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
@ -239,7 +239,7 @@ fn demo_run_files() -> PaginatedRunFileList {
}
pub(crate) async fn get_run_billing(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
@ -247,7 +247,7 @@ pub(crate) async fn get_run_billing(
}
pub(crate) async fn get_run_settings(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
@ -255,7 +255,7 @@ pub(crate) async fn get_run_settings(
}
pub(crate) async fn generate_preview_url_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
@ -267,7 +267,7 @@ pub(crate) async fn generate_preview_url_stub(
}
pub(crate) async fn create_ssh_access_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
@ -279,7 +279,7 @@ pub(crate) async fn create_ssh_access_stub(
}
pub(crate) async fn list_sandbox_files_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
@ -296,7 +296,7 @@ pub(crate) async fn list_sandbox_files_stub(
}
pub(crate) async fn get_sandbox_file_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
@ -304,7 +304,7 @@ pub(crate) async fn get_sandbox_file_stub(
}
pub(crate) async fn put_sandbox_file_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
@ -312,7 +312,7 @@ pub(crate) async fn put_sandbox_file_stub(
}
pub(crate) async fn get_run_status(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
@ -331,7 +331,7 @@ pub(crate) struct ResolveRunParams {
}
pub(crate) async fn get_questions_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
Query(pagination): Query<PaginationParams>,
@ -340,7 +340,7 @@ pub(crate) async fn get_questions_stub(
}
pub(crate) async fn answer_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path((_id, _qid)): Path<(String, String)>,
) -> Response {
@ -348,7 +348,7 @@ pub(crate) async fn answer_stub(
}
pub(crate) async fn run_events_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
@ -373,7 +373,7 @@ pub(crate) async fn run_events_stub(
}
pub(crate) async fn checkpoint_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
@ -381,7 +381,7 @@ pub(crate) async fn checkpoint_stub(
}
pub(crate) async fn cancel_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
@ -397,7 +397,7 @@ pub(crate) async fn cancel_stub(
}
pub(crate) async fn pause_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
@ -411,7 +411,7 @@ pub(crate) async fn pause_stub(
}
pub(crate) async fn unpause_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
@ -421,7 +421,7 @@ pub(crate) async fn unpause_stub(
const DEMO_GRAPH_DOT: &str = "digraph demo {\n graph [goal=\"Demo\"]\n rankdir=LR\n start [shape=Mdiamond, label=\"Start\"]\n detect [label=\"Detect\\nDrift\"]\n exit [shape=Msquare, label=\"Exit\"]\n propose [label=\"Propose\\nChanges\"]\n review [label=\"Review\\nChanges\"]\n apply [label=\"Apply\\nChanges\"]\n start -> detect\n detect -> exit [label=\"No drift\"]\n detect -> propose [label=\"Drift found\"]\n propose -> review\n review -> propose [label=\"Revise\"]\n review -> apply [label=\"Accept\"]\n apply -> exit\n}";
pub(crate) async fn get_run_graph(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
@ -429,7 +429,7 @@ pub(crate) async fn get_run_graph(
}
pub(crate) async fn get_run_graph_source(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
@ -442,7 +442,7 @@ pub(crate) async fn get_run_graph_source(
}
pub(crate) async fn list_secrets(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
) -> Response {
(
@ -468,7 +468,7 @@ pub(crate) async fn list_secrets(
}
pub(crate) async fn create_secret(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Json(body): Json<CreateSecretRequest>,
) -> Response {
@ -485,7 +485,7 @@ pub(crate) async fn create_secret(
}
pub(crate) async fn delete_secret_by_name(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Json(_body): Json<DeleteSecretRequest>,
) -> Response {
@ -493,7 +493,7 @@ pub(crate) async fn delete_secret_by_name(
}
pub(crate) async fn get_github_repo(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path((owner, name)): Path<(String, String)>,
) -> Response {
@ -513,7 +513,7 @@ pub(crate) async fn get_github_repo(
}
pub(crate) async fn run_diagnostics(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
) -> Response {
(
@ -545,7 +545,7 @@ pub(crate) async fn run_diagnostics(
// ── Insights ───────────────────────────────────────────────────────────
pub(crate) async fn list_saved_queries(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> Response {
@ -553,7 +553,7 @@ pub(crate) async fn list_saved_queries(
}
pub(crate) async fn save_query_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
) -> Response {
(
@ -564,7 +564,7 @@ pub(crate) async fn save_query_stub(
}
pub(crate) async fn get_saved_query(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
@ -575,7 +575,7 @@ pub(crate) async fn get_saved_query(
}
pub(crate) async fn update_query_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
@ -587,7 +587,7 @@ pub(crate) async fn update_query_stub(
}
pub(crate) async fn delete_query_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
@ -595,7 +595,7 @@ pub(crate) async fn delete_query_stub(
}
pub(crate) async fn execute_query_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
) -> Response {
(
@ -611,7 +611,7 @@ pub(crate) async fn execute_query_stub(
}
pub(crate) async fn list_query_history(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> Response {
@ -621,7 +621,7 @@ pub(crate) async fn list_query_history(
// ── Settings ───────────────────────────────────────────────────────────
pub(crate) async fn get_server_settings(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
) -> Response {
(StatusCode::OK, Json(settings::server_settings())).into_response()
@ -630,7 +630,7 @@ pub(crate) async fn get_server_settings(
// ── System ────────────────────────────────────────────────────────────
pub(crate) async fn attach_events_stub(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
) -> Response {
let events = vec![
@ -663,7 +663,7 @@ pub(crate) async fn attach_events_stub(
}
pub(crate) async fn get_system_info(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
) -> Response {
(
@ -688,7 +688,7 @@ pub(crate) async fn get_system_info(
}
pub(crate) async fn get_system_disk_usage(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Query(params): Query<crate::server::DfParams>,
) -> Response {
@ -732,7 +732,7 @@ pub(crate) async fn get_system_disk_usage(
}
pub(crate) async fn prune_runs(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
) -> Response {
(
@ -759,7 +759,7 @@ pub(crate) async fn prune_runs(
// ── Usage ──────────────────────────────────────────────────────────────
pub(crate) async fn get_aggregate_billing(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
) -> Response {
(StatusCode::OK, Json(billing::aggregate())).into_response()

View file

@ -124,6 +124,10 @@ impl ApiError {
pub fn status(&self) -> StatusCode {
self.status
}
pub(crate) fn code(&self) -> Option<&str> {
self.code.as_deref()
}
}
impl From<Error> for ApiError {

View file

@ -1,13 +1,15 @@
use anyhow::{Result, anyhow};
use axum::extract::FromRequestParts;
#[cfg(test)]
use axum::http::header;
#[cfg(test)]
use axum::http::request::Parts;
use fabro_static::EnvVars;
use fabro_types::settings::{ServerAuthMethod, ServerNamespace};
use fabro_types::{IdpIdentity, RunAuthMethod};
use fabro_types::{AuthMethod, IdpIdentity};
use fabro_util::dev_token::validate_dev_token_format;
use hmac::{Hmac, Mac};
use sha2::Sha256;
#[cfg(test)]
use tracing::info;
use crate::auth::{self, JwtError, JwtSigningKey, KeyDeriveError};
@ -23,7 +25,7 @@ pub struct VerifiedAuth {
pub email: String,
pub avatar_url: String,
pub user_url: String,
pub auth_method: RunAuthMethod,
pub auth_method: AuthMethod,
pub identity: Option<IdpIdentity>,
}
@ -49,7 +51,6 @@ impl ConfiguredAuth {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AuthMode {
Enabled(ConfiguredAuth),
Disabled,
}
pub fn resolve_auth_mode(settings: &ServerNamespace) -> Result<AuthMode> {
@ -183,14 +184,14 @@ pub(crate) fn dev_token_matches(provided: &str, expected: &str) -> bool {
expected_mac.verify_slice(&provided_mac).is_ok()
}
fn config_allows_run_auth_method(config: &ConfiguredAuth, method: RunAuthMethod) -> bool {
fn config_allows_run_auth_method(config: &ConfiguredAuth, method: AuthMethod) -> bool {
match method {
RunAuthMethod::Disabled => false,
RunAuthMethod::DevToken => config.methods.contains(&ServerAuthMethod::DevToken),
RunAuthMethod::Github => config.methods.contains(&ServerAuthMethod::Github),
AuthMethod::DevToken => config.methods.contains(&ServerAuthMethod::DevToken),
AuthMethod::Github => config.methods.contains(&ServerAuthMethod::Github),
}
}
#[cfg(test)]
pub(crate) fn bearer_token(parts: &Parts) -> Option<Result<&str, ApiError>> {
let value = parts.headers.get(header::AUTHORIZATION)?;
let Ok(value) = value.to_str() else {
@ -203,7 +204,10 @@ pub(crate) fn bearer_token(parts: &Parts) -> Option<Result<&str, ApiError>> {
)
}
fn authenticate_jwt_bearer(token: &str, config: &ConfiguredAuth) -> Result<VerifiedAuth, ApiError> {
pub(crate) fn authenticate_jwt_bearer(
token: &str,
config: &ConfiguredAuth,
) -> Result<VerifiedAuth, ApiError> {
let Some(jwt_key) = config.jwt_key.as_ref() else {
return Err(ApiError::unauthorized());
};
@ -252,6 +256,7 @@ fn authenticate_jwt_bearer(token: &str, config: &ConfiguredAuth) -> Result<Verif
})
}
#[cfg(test)]
fn authenticate_bearer(
parts: &Parts,
token: &str,
@ -271,7 +276,7 @@ fn authenticate_bearer(
authenticate_jwt_bearer(token, config)
}
fn looks_like_jwt(token: &str) -> bool {
pub(crate) fn looks_like_jwt(token: &str) -> bool {
let mut segments = token.split('.');
matches!(
(
@ -285,15 +290,14 @@ fn looks_like_jwt(token: &str) -> bool {
)
}
#[cfg(test)]
fn authenticate_parts(parts: &Parts) -> Result<Option<VerifiedAuth>, ApiError> {
let auth_mode = parts
.extensions
.get::<AuthMode>()
.expect("AuthMode extension must be added to the router");
let AuthMode::Enabled(config) = auth_mode else {
return Ok(None);
};
let AuthMode::Enabled(config) = auth_mode;
authenticate_bearer(
parts,
@ -303,70 +307,6 @@ fn authenticate_parts(parts: &Parts) -> Result<Option<VerifiedAuth>, ApiError> {
.map(Some)
}
pub struct AuthenticatedService;
pub fn authenticate_service_parts(parts: &Parts) -> Result<(), ApiError> {
authenticate_parts(parts).map(|_| ())
}
impl<S: Send + Sync> FromRequestParts<S> for AuthenticatedService {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
authenticate_service_parts(parts)?;
Ok(Self)
}
}
pub struct AuthenticatedSubject {
pub login: Option<String>,
pub name: String,
pub email: String,
pub avatar_url: String,
pub user_url: String,
pub identity: Option<IdpIdentity>,
pub auth_method: RunAuthMethod,
}
impl<S: Send + Sync> FromRequestParts<S> for AuthenticatedSubject {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let auth_mode = parts
.extensions
.get::<AuthMode>()
.expect("AuthMode extension must be added to the router");
match auth_mode {
AuthMode::Disabled => Ok(Self {
login: None,
name: String::new(),
email: String::new(),
avatar_url: String::new(),
user_url: String::new(),
identity: None,
auth_method: RunAuthMethod::Disabled,
}),
AuthMode::Enabled(config) => {
let auth = authenticate_bearer(
parts,
bearer_token(parts).ok_or_else(ApiError::unauthorized)??,
config,
)?;
Ok(Self {
login: Some(auth.login),
name: auth.name,
email: auth.email,
avatar_url: auth.avatar_url,
user_url: auth.user_url,
identity: auth.identity,
auth_method: auth.auth_method,
})
}
}
}
}
pub fn auth_method_name(method: ServerAuthMethod) -> &'static str {
match method {
ServerAuthMethod::DevToken => "dev-token",
@ -381,14 +321,11 @@ mod tests {
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Json, Router};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use fabro_config::{Error as ConfigError, ServerSettingsBuilder};
use fabro_types::IdpIdentity;
use fabro_types::settings::ServerAuthMethod;
use tower::ServiceExt;
use tracing::field::{Field, Visit};
use tracing::{Event, Subscriber, subscriber};
use tracing_subscriber::layer::{Context, SubscriberExt};
@ -405,47 +342,6 @@ mod tests {
None
}
async fn protected_handler(_auth: AuthenticatedService) -> impl IntoResponse {
"ok"
}
async fn subject_handler(subject: AuthenticatedSubject) -> impl IntoResponse {
Json(serde_json::json!({
"login": subject.login,
"name": subject.name,
"email": subject.email,
"avatar_url": subject.avatar_url,
"user_url": subject.user_url,
"idp_issuer": subject.identity.as_ref().map(|identity| identity.issuer().to_string()),
"idp_subject": subject.identity.as_ref().map(|identity| identity.subject().to_string()),
"auth_method": subject.auth_method,
}))
}
fn test_router(mode: AuthMode) -> Router {
Router::new()
.route("/test", get(protected_handler))
.layer(axum::Extension(mode))
}
fn subject_router(mode: AuthMode) -> Router {
Router::new()
.route("/subject", get(subject_handler))
.layer(axum::Extension(mode))
}
macro_rules! response_json {
($response:expr) => {
fabro_test::expect_axum_json($response, StatusCode::OK, concat!(file!(), ":", line!()))
};
}
macro_rules! assert_status {
($response:expr, $expected:expr) => {
fabro_test::assert_axum_status($response, $expected, concat!(file!(), ":", line!()))
};
}
async fn error_json(err: ApiError) -> serde_json::Value {
let response = err.into_response();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
@ -491,7 +387,7 @@ mod tests {
email: "octocat@example.com".to_string(),
avatar_url: "https://example.com/octocat.png".to_string(),
user_url: "https://github.com/octocat".to_string(),
auth_method: RunAuthMethod::Github,
auth_method: AuthMethod::Github,
}
}
@ -636,9 +532,7 @@ methods = ["dev-token"]
_ => None,
})
.expect("dev-token auth should resolve");
let AuthMode::Enabled(config) = mode else {
panic!("expected enabled mode");
};
let AuthMode::Enabled(config) = mode;
assert_eq!(config.methods, vec![ServerAuthMethod::DevToken]);
assert!(config.dev_token.is_some());
assert!(config.jwt_key.is_some());
@ -672,9 +566,7 @@ url = "http://localhost:4000"
_ => None,
})
.expect("dev-token auth should resolve");
let AuthMode::Enabled(config) = mode else {
panic!("expected enabled mode");
};
let AuthMode::Enabled(config) = mode;
assert_eq!(config.jwt_issuer.as_deref(), Some("http://localhost:4000"));
}
@ -705,9 +597,7 @@ url = ""
_ => None,
})
.expect("dev-token auth should resolve");
let AuthMode::Enabled(config) = mode else {
panic!("expected enabled mode");
};
let AuthMode::Enabled(config) = mode;
assert_eq!(config.jwt_issuer.as_deref(), Some("fabro-server"));
}
@ -816,76 +706,61 @@ client_id = "Iv1.test"
})
.expect("github auth should resolve");
let AuthMode::Enabled(config) = mode else {
panic!("expected enabled mode");
};
let AuthMode::Enabled(config) = mode;
assert_eq!(config.methods, vec![ServerAuthMethod::Github]);
assert!(config.jwt_key.is_some());
assert_eq!(config.jwt_issuer.as_deref(), Some("http://localhost:3000"));
}
#[tokio::test]
async fn disabled_mode_allows_request() {
let app = test_router(AuthMode::Disabled);
let response = app
.oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap())
.await
.unwrap();
assert_status!(response, StatusCode::OK).await;
}
#[tokio::test]
async fn rejects_missing_credentials() {
let app = test_router(dev_token_mode());
let response = app
.oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap())
.await
.unwrap();
assert_status!(response, StatusCode::UNAUTHORIZED).await;
let parts = request_parts(
dev_token_mode(),
Request::builder().uri("/test").body(Body::empty()).unwrap(),
);
let err = authenticate_parts(&parts).unwrap_err();
assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn rejects_dev_token_bearer_without_translation() {
let app = subject_router(dev_token_mode());
let response = app
.oneshot(
Request::builder()
.uri("/subject")
.header(
"authorization",
"Bearer fabro_dev_abababababababababababababababababababababababababababababababab",
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_status!(response, StatusCode::UNAUTHORIZED).await;
let parts = request_parts(
dev_token_mode(),
Request::builder()
.uri("/subject")
.header(
"authorization",
"Bearer fabro_dev_abababababababababababababababababababababababababababababab",
)
.body(Body::empty())
.unwrap(),
);
let err = authenticate_parts(&parts).unwrap_err();
assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn subject_reports_profile_fields_from_jwt() {
let app = subject_router(github_jwt_mode());
let token = issue_github_token(chrono::Duration::minutes(10));
let response = app
.oneshot(
Request::builder()
.uri("/subject")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let json = response_json!(response).await;
assert_eq!(json["login"], "octocat");
assert_eq!(json["name"], "The Octocat");
assert_eq!(json["email"], "octocat@example.com");
assert_eq!(json["avatar_url"], "https://example.com/octocat.png");
assert_eq!(json["user_url"], "https://github.com/octocat");
assert_eq!(json["idp_issuer"], "https://github.com");
assert_eq!(json["idp_subject"], "12345");
assert_eq!(json["auth_method"], "github");
let parts = request_parts(
github_jwt_mode(),
Request::builder()
.uri("/subject")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
);
let auth = authenticate_parts(&parts).unwrap().unwrap();
assert_eq!(auth.login, "octocat");
assert_eq!(auth.name, "The Octocat");
assert_eq!(auth.email, "octocat@example.com");
assert_eq!(auth.avatar_url, "https://example.com/octocat.png");
assert_eq!(auth.user_url, "https://github.com/octocat");
assert_eq!(
auth.identity,
Some(IdpIdentity::new("https://github.com", "12345").unwrap())
);
assert_eq!(auth.auth_method, AuthMethod::Github);
}
#[test]
@ -902,7 +777,7 @@ client_id = "Iv1.test"
let auth = authenticate_parts(&parts).unwrap().unwrap();
assert_eq!(auth.login, "octocat");
assert_eq!(auth.auth_method, RunAuthMethod::Github);
assert_eq!(auth.auth_method, AuthMethod::Github);
assert_eq!(
auth.identity,
Some(IdpIdentity::new("https://github.com", "12345").unwrap())

View file

@ -25,6 +25,7 @@ pub mod install;
pub mod ip_allowlist;
pub mod jwt_auth;
pub mod manifest_validation;
mod principal_middleware;
mod request_id;
mod run_files;
mod run_files_security;
@ -37,6 +38,7 @@ mod server_secrets;
mod spawn_env;
mod startup;
pub mod static_files;
pub mod test_support;
pub mod web_auth;
mod worker_token;

View file

@ -0,0 +1,433 @@
use std::convert::Infallible;
use std::sync::{Arc, Mutex};
use axum::extract::{FromRequestParts, Path, Request, State};
use axum::http::request::Parts;
use axum::http::{HeaderMap, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use fabro_types::{CommandOutputStream, Principal, RunBlobId, RunId, StageId, UserPrincipal};
use jsonwebtoken::dangerous::insecure_decode;
use serde::Deserialize;
use crate::auth::JwtError;
use crate::error::ApiError;
use crate::jwt_auth::{self, AuthMode, ConfiguredAuth};
use crate::server::{AppState, parse_blob_id_path, parse_run_id_path, parse_stage_id_path};
use crate::worker_token::{self, WORKER_TOKEN_ISSUER};
#[derive(Clone, Debug)]
pub(crate) struct RequestAuthContext {
pub principal: Principal,
pub auth_status: AuthStatus,
pub auth_error_code: Option<&'static str>,
pub user_profile: Option<UserProfile>,
}
#[derive(Clone, Debug)]
pub(crate) struct UserProfile {
pub name: String,
pub email: String,
pub avatar_url: String,
pub user_url: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AuthStatus {
Missing,
Invalid,
Expired,
Authenticated,
}
#[derive(Clone)]
pub(crate) struct AuthContextSlot(pub(crate) Arc<Mutex<RequestAuthContext>>);
#[allow(
dead_code,
reason = "Route migrations use RequestAuth first; retained for principal-only guards."
)]
pub(crate) struct RequestPrincipal(pub(crate) Principal);
pub(crate) struct RequestAuth(pub(crate) AuthContextSlot);
pub(crate) struct RequiredUser(pub(crate) UserPrincipal);
pub(crate) struct RequireRunScoped(pub(crate) RunId);
pub(crate) struct RequireRunBlob(pub(crate) RunId, pub(crate) RunBlobId);
pub(crate) struct RequireStageArtifact(pub(crate) RunId, pub(crate) StageId);
pub(crate) struct RequireCommandLog(
pub(crate) RunId,
pub(crate) StageId,
pub(crate) CommandOutputStream,
);
#[derive(Clone, Debug)]
pub(crate) struct AuthenticatedUser {
pub principal: UserPrincipal,
pub profile: UserProfile,
}
#[derive(Debug, Deserialize)]
struct IssuerOnlyClaims {
iss: String,
}
impl RequestAuthContext {
#[must_use]
pub(crate) fn initial() -> Self {
Self {
principal: Principal::anonymous(),
auth_status: AuthStatus::Missing,
auth_error_code: None,
user_profile: None,
}
}
}
impl AuthStatus {
#[must_use]
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Missing => "missing",
Self::Invalid => "invalid",
Self::Expired => "expired",
Self::Authenticated => "authenticated",
}
}
}
impl AuthContextSlot {
#[must_use]
pub(crate) fn initial() -> Self {
Self(Arc::new(Mutex::new(RequestAuthContext::initial())))
}
pub(crate) fn replace(&self, context: RequestAuthContext) {
*self.0.lock().expect("auth context lock poisoned") = context;
}
#[must_use]
pub(crate) fn snapshot(&self) -> RequestAuthContext {
self.0.lock().expect("auth context lock poisoned").clone()
}
}
impl<S: Send + Sync> FromRequestParts<S> for RequestPrincipal {
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
let slot = parts
.extensions
.get::<AuthContextSlot>()
.cloned()
.unwrap_or_else(AuthContextSlot::initial);
Ok(Self(slot.snapshot().principal))
}
}
impl<S: Send + Sync> FromRequestParts<S> for RequestAuth {
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
let slot = parts
.extensions
.get::<AuthContextSlot>()
.cloned()
.unwrap_or_else(AuthContextSlot::initial);
Ok(Self(slot))
}
}
impl<S: Send + Sync> FromRequestParts<S> for RequiredUser {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
let slot = parts
.extensions
.get::<AuthContextSlot>()
.cloned()
.unwrap_or_else(AuthContextSlot::initial);
require_user(&slot).map(Self)
}
}
impl FromRequestParts<Arc<AppState>> for RequireRunScoped {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let Path(id): Path<String> = Path::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
let run_id = parse_run_id_path(&id)?;
require_worker_or_user_for_run(&principal_from_parts(parts), &run_id)
.map_err(IntoResponse::into_response)?;
Ok(Self(run_id))
}
}
impl FromRequestParts<Arc<AppState>> for RequireRunBlob {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let Path((id, blob_id)): Path<(String, String)> = Path::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
let run_id = parse_run_id_path(&id)?;
let blob_id = parse_blob_id_path(&blob_id)?;
require_worker_or_user_for_run(&principal_from_parts(parts), &run_id)
.map_err(IntoResponse::into_response)?;
Ok(Self(run_id, blob_id))
}
}
impl FromRequestParts<Arc<AppState>> for RequireStageArtifact {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let Path((id, stage_id)): Path<(String, String)> = Path::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
let run_id = parse_run_id_path(&id)?;
let stage_id = parse_stage_id_path(&stage_id)?;
require_worker_or_user_for_run(&principal_from_parts(parts), &run_id)
.map_err(IntoResponse::into_response)?;
Ok(Self(run_id, stage_id))
}
}
impl FromRequestParts<Arc<AppState>> for RequireCommandLog {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let Path((id, stage_id, stream)): Path<(String, String, String)> =
Path::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
let run_id = parse_run_id_path(&id)?;
let stage_id = parse_stage_id_path(&stage_id)?;
let stream = stream
.parse::<CommandOutputStream>()
.map_err(|_| ApiError::bad_request("Invalid command log stream.").into_response())?;
require_worker_or_user_for_run(&principal_from_parts(parts), &run_id)
.map_err(IntoResponse::into_response)?;
Ok(Self(run_id, stage_id, stream))
}
}
pub(crate) async fn principal_middleware(
State(state): State<Arc<AppState>>,
mut req: Request,
next: Next,
) -> Response {
let slot = req
.extensions()
.get::<AuthContextSlot>()
.cloned()
.unwrap_or_else(|| {
let slot = AuthContextSlot::initial();
req.extensions_mut().insert(slot.clone());
slot
});
let context = classify_request(&req, state.as_ref());
slot.replace(context);
next.run(req).await
}
fn principal_from_parts(parts: &Parts) -> Principal {
parts
.extensions
.get::<AuthContextSlot>()
.map_or_else(Principal::anonymous, |slot| slot.snapshot().principal)
}
pub(crate) fn require_user(slot: &AuthContextSlot) -> Result<UserPrincipal, ApiError> {
let context = slot.snapshot();
match context.principal {
Principal::User(user) => Ok(user),
_ => Err(auth_rejection(context.auth_status, context.auth_error_code)),
}
}
pub(crate) fn require_authenticated_user(
slot: &AuthContextSlot,
) -> Result<AuthenticatedUser, ApiError> {
let context = slot.snapshot();
match context.principal {
Principal::User(principal) => {
let Some(profile) = context.user_profile else {
return Err(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"Authenticated user profile missing.",
));
};
Ok(AuthenticatedUser { principal, profile })
}
_ => Err(auth_rejection(context.auth_status, context.auth_error_code)),
}
}
#[allow(
dead_code,
reason = "Worker-only route migration is staged behind the shared context."
)]
pub(crate) fn require_worker_for_run(
principal: &Principal,
route_run_id: &RunId,
) -> Result<(), ApiError> {
match principal {
Principal::Worker { run_id } if run_id == route_run_id => Ok(()),
Principal::Worker { .. } => Err(ApiError::forbidden()),
_ => Err(ApiError::unauthorized()),
}
}
#[allow(
dead_code,
reason = "Run-scoped route migration is staged behind the shared context."
)]
pub(crate) fn require_worker_or_user_for_run(
principal: &Principal,
route_run_id: &RunId,
) -> Result<(), ApiError> {
match principal {
Principal::User(_) => Ok(()),
Principal::Worker { run_id } if run_id == route_run_id => Ok(()),
Principal::Worker { .. } => Err(ApiError::forbidden()),
_ => Err(ApiError::unauthorized()),
}
}
#[allow(
dead_code,
reason = "Webhook route stamps the slot inline; guard is for future webhook consumers."
)]
pub(crate) fn require_webhook(principal: &Principal) -> Result<String, ApiError> {
match principal {
Principal::Webhook { delivery_id } => Ok(delivery_id.clone()),
_ => Err(ApiError::unauthorized()),
}
}
fn classify_request(req: &Request, state: &AppState) -> RequestAuthContext {
let AuthMode::Enabled(config) = req
.extensions()
.get::<AuthMode>()
.expect("AuthMode extension must be added to the router");
let token = match bearer_from_headers(req.headers()) {
BearerCredential::Missing => return RequestAuthContext::initial(),
BearerCredential::Invalid => {
return rejected(AuthStatus::Invalid, Some("unauthorized"));
}
BearerCredential::Present(token) => token,
};
if token.starts_with("fabro_refresh_") {
return rejected(AuthStatus::Invalid, Some("unauthorized"));
}
if !jwt_auth::looks_like_jwt(token) {
return rejected(AuthStatus::Invalid, Some("access_token_invalid"));
}
let issuer = match insecure_decode::<IssuerOnlyClaims>(token) {
Ok(data) => data.claims.iss,
Err(_) => return rejected(AuthStatus::Invalid, Some("access_token_invalid")),
};
if issuer == WORKER_TOKEN_ISSUER {
return match worker_token::decode_worker_token(token, state.worker_token_keys()) {
Ok(run_id) => authenticated(Principal::worker(run_id), None),
Err(JwtError::AccessTokenExpired) => {
rejected(AuthStatus::Expired, Some("access_token_expired"))
}
Err(JwtError::AccessTokenInvalid) => {
rejected(AuthStatus::Invalid, Some("access_token_invalid"))
}
};
}
classify_user_token(token, config)
}
enum BearerCredential<'a> {
Missing,
Invalid,
Present(&'a str),
}
fn bearer_from_headers(headers: &HeaderMap) -> BearerCredential<'_> {
let Some(value) = headers.get(header::AUTHORIZATION) else {
return BearerCredential::Missing;
};
let Ok(value) = value.to_str() else {
return BearerCredential::Invalid;
};
match value.strip_prefix("Bearer ") {
Some(token) => BearerCredential::Present(token),
None => BearerCredential::Invalid,
}
}
fn classify_user_token(token: &str, config: &ConfiguredAuth) -> RequestAuthContext {
let auth = match jwt_auth::authenticate_jwt_bearer(token, config) {
Ok(auth) => auth,
Err(err) if err.code() == Some("access_token_expired") => {
return rejected(AuthStatus::Expired, Some("access_token_expired"));
}
Err(err) if err.code() == Some("access_token_invalid") => {
return rejected(AuthStatus::Invalid, Some("access_token_invalid"));
}
Err(_) => return rejected(AuthStatus::Invalid, Some("unauthorized")),
};
let Some(identity) = auth.identity else {
return rejected(AuthStatus::Invalid, Some("access_token_invalid"));
};
let principal = Principal::user(identity, auth.login, auth.auth_method);
let profile = UserProfile {
name: auth.name,
email: auth.email,
avatar_url: auth.avatar_url,
user_url: auth.user_url,
};
authenticated(principal, Some(profile))
}
fn authenticated(principal: Principal, user_profile: Option<UserProfile>) -> RequestAuthContext {
RequestAuthContext {
principal,
auth_status: AuthStatus::Authenticated,
auth_error_code: None,
user_profile,
}
}
fn rejected(status: AuthStatus, code: Option<&'static str>) -> RequestAuthContext {
RequestAuthContext {
principal: Principal::anonymous(),
auth_status: status,
auth_error_code: code,
user_profile: None,
}
}
fn auth_rejection(status: AuthStatus, code: Option<&'static str>) -> ApiError {
match (status, code) {
(AuthStatus::Expired | AuthStatus::Invalid, Some(code)) => {
ApiError::unauthorized_with_code("Authentication required.", code)
}
_ => ApiError::unauthorized(),
}
}

View file

@ -42,7 +42,7 @@ use serde::Deserialize;
use tokio::sync::{Mutex, watch};
use crate::error::ApiError;
use crate::jwt_auth::AuthenticatedService;
use crate::principal_middleware::RequiredUser;
use crate::run_files_security::{RunFilesMetrics, is_sensitive};
use crate::server::{AppState, parse_run_id_path};
@ -185,7 +185,7 @@ where
/// set enforced by [`RunFilesMetrics::emit`] — no paths, contents, or raw
/// git stderr.
pub async fn list_run_files(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Query(params): Query<ListRunFilesParams>,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,68 @@
use std::sync::Arc;
use axum::extract::Request;
use axum::http::{HeaderValue, header};
use axum::middleware::Next;
use axum::response::Response;
use axum::{Router, middleware};
use fabro_types::settings::ServerAuthMethod;
use crate::auth;
use crate::ip_allowlist::IpAllowlistConfig;
use crate::jwt_auth::{AuthMode, ConfiguredAuth};
use crate::server::{self, AppState, RouterOptions};
pub const TEST_DEV_TOKEN: &str =
"fabro_dev_abababababababababababababababababababababababababababababababab";
pub const TEST_SESSION_SECRET: &str =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
#[doc(hidden)]
#[must_use]
pub fn test_auth_mode() -> AuthMode {
AuthMode::Enabled(ConfiguredAuth {
methods: vec![ServerAuthMethod::DevToken, ServerAuthMethod::Github],
dev_token: Some(TEST_DEV_TOKEN.to_string()),
jwt_key: Some(
auth::derive_jwt_key(TEST_SESSION_SECRET.as_bytes())
.expect("test jwt signing key should derive"),
),
jwt_issuer: Some("https://fabro.test".to_string()),
})
}
#[doc(hidden)]
pub fn build_test_router(state: Arc<AppState>) -> Router {
with_test_user(server::build_router(state, test_auth_mode()))
}
#[doc(hidden)]
pub fn build_test_router_with_options(
state: Arc<AppState>,
ip_allowlist_config: Arc<IpAllowlistConfig>,
options: RouterOptions,
) -> Router {
with_test_user(server::build_router_with_options(
state,
&test_auth_mode(),
ip_allowlist_config,
options,
))
}
#[doc(hidden)]
pub fn with_test_user(router: Router) -> Router {
router.layer(middleware::from_fn(inject_test_user_bearer))
}
async fn inject_test_user_bearer(mut req: Request, next: Next) -> Response {
if req.uri().path().starts_with("/api/") && !req.headers().contains_key(header::AUTHORIZATION) {
req.headers_mut().insert(
header::AUTHORIZATION,
HeaderValue::from_static(
"Bearer fabro_dev_abababababababababababababababababababababababababababababababab",
),
);
}
next.run(req).await
}

View file

@ -10,7 +10,7 @@ use cookie::{Cookie, CookieJar, Key, SameSite};
use fabro_redact::DisplaySafeUrl;
use fabro_static::EnvVars;
use fabro_types::settings::ServerAuthMethod;
use fabro_types::{IdpIdentity, RunAuthMethod};
use fabro_types::{AuthMethod, IdpIdentity};
use fabro_util::dev_token::validate_dev_token_format;
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
use serde::{Deserialize, Serialize};
@ -18,9 +18,8 @@ use serde_json::json;
use tracing::{debug, error, info, warn};
use crate::auth::{GithubEndpoints, browser_shell};
use crate::jwt_auth::{
AuthMode, AuthenticatedService, AuthenticatedSubject, auth_method_name, dev_token_matches,
};
use crate::jwt_auth::{AuthMode, auth_method_name, dev_token_matches};
use crate::principal_middleware::{RequestAuth, RequiredUser, require_authenticated_user};
use crate::server::AppState;
pub const SESSION_COOKIE_NAME: &str = "__fabro_session";
@ -31,7 +30,7 @@ const OAUTH_STATE_TTL_MINUTES: i64 = 30;
pub struct SessionCookie {
pub v: u8,
pub login: String,
pub auth_method: RunAuthMethod,
pub auth_method: AuthMethod,
pub identity: Option<IdpIdentity>,
pub name: String,
pub email: String,
@ -255,32 +254,28 @@ fn callback_error_redirect(
}
fn auth_methods_from_mode(auth_mode: &AuthMode) -> Vec<String> {
match auth_mode {
AuthMode::Enabled(config) => config
.methods
.iter()
.map(|method| auth_method_name(*method).to_string())
.collect(),
AuthMode::Disabled => Vec::new(),
}
let AuthMode::Enabled(config) = auth_mode;
config
.methods
.iter()
.map(|method| auth_method_name(*method).to_string())
.collect()
}
fn auth_method_enabled(auth_mode: &AuthMode, method: ServerAuthMethod) -> bool {
matches!(auth_mode, AuthMode::Enabled(config) if config.methods.contains(&method))
let AuthMode::Enabled(config) = auth_mode;
config.methods.contains(&method)
}
fn dev_token_from_mode(auth_mode: &AuthMode) -> Option<String> {
match auth_mode {
AuthMode::Enabled(config) => config.dev_token.clone(),
AuthMode::Disabled => None,
}
let AuthMode::Enabled(config) = auth_mode;
config.dev_token.clone()
}
fn session_provider(auth_method: RunAuthMethod) -> &'static str {
fn session_provider(auth_method: AuthMethod) -> &'static str {
match auth_method {
RunAuthMethod::Disabled => "disabled",
RunAuthMethod::DevToken => "dev-token",
RunAuthMethod::Github => "github",
AuthMethod::DevToken => "dev-token",
AuthMethod::Github => "github",
}
}
@ -332,7 +327,7 @@ async fn login_dev_token(
let session = SessionCookie {
v: 2,
login: "dev".to_string(),
auth_method: RunAuthMethod::DevToken,
auth_method: AuthMethod::DevToken,
identity: Some(IdpIdentity::new("fabro:dev", "dev").expect("non-empty dev identity")),
name: "Development User".to_string(),
email: "dev@localhost".to_string(),
@ -724,7 +719,7 @@ async fn callback_github(
let session = SessionCookie {
v: 2,
login: profile.login.clone(),
auth_method: RunAuthMethod::Github,
auth_method: AuthMethod::Github,
identity: Some(
IdpIdentity::new("https://github.com", profile.id.to_string())
.expect("GitHub profile id should produce a valid identity"),
@ -784,42 +779,38 @@ async fn logout(State(state): State<Arc<AppState>>) -> Response {
response
}
async fn auth_me(subject: AuthenticatedSubject, headers: HeaderMap) -> Response {
if subject.login.is_none() {
warn!(
has_cookie = headers.get(header::COOKIE).is_some(),
"Auth check failed: authenticated subject missing"
);
return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"}));
}
async fn auth_me(RequestAuth(auth_slot): RequestAuth, headers: HeaderMap) -> Response {
let authenticated = match require_authenticated_user(&auth_slot) {
Ok(authenticated) => authenticated,
Err(err) => {
warn!(
has_cookie = headers.get(header::COOKIE).is_some(),
"Auth check failed: authenticated subject missing"
);
return err.into_response();
}
};
let demo_mode = parse_cookie_header(&headers)
.get("fabro-demo")
.is_some_and(|cookie| cookie.value() == "1");
Json(AuthMeResponse {
user: SessionUser {
login: subject.login.expect("checked above"),
name: subject.name,
email: subject.email,
idp_issuer: subject
.identity
.as_ref()
.map(|identity| identity.issuer().to_string()),
idp_subject: subject
.identity
.as_ref()
.map(|identity| identity.subject().to_string()),
avatar_url: subject.avatar_url,
user_url: subject.user_url,
login: authenticated.principal.login.clone(),
name: authenticated.profile.name,
email: authenticated.profile.email,
idp_issuer: Some(authenticated.principal.identity.issuer().to_string()),
idp_subject: Some(authenticated.principal.identity.subject().to_string()),
avatar_url: authenticated.profile.avatar_url,
user_url: authenticated.profile.user_url,
},
provider: session_provider(subject.auth_method).to_string(),
provider: session_provider(authenticated.principal.auth_method).to_string(),
demo_mode,
})
.into_response()
}
async fn toggle_demo(
_auth: AuthenticatedService,
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Json(payload): Json<DemoToggleRequest>,
) -> Response {
@ -848,7 +839,7 @@ mod tests {
use axum_extra::extract::cookie::Key;
use fabro_config::{RunLayer, ServerSettingsBuilder};
use fabro_types::settings::server::ServerAuthMethod;
use fabro_types::{IdpIdentity, RunAuthMethod};
use fabro_types::{AuthMethod, IdpIdentity};
use serde_json::json;
use tower::ServiceExt;
@ -943,12 +934,19 @@ client_id = "github-client-id"
RunLayer::default(),
Some("web-auth-test-key-material-0123456789"),
);
let middleware_state = state.clone();
let translation_state = state.clone();
let principal_state = state.clone();
axum::Router::new()
.nest("/auth", routes())
.nest("/api/v1", api_routes())
.nest(
"/api/v1",
api_routes().layer(axum::middleware::from_fn_with_state(
principal_state,
crate::principal_middleware::principal_middleware,
)),
)
.layer(axum::middleware::from_fn_with_state(
middleware_state,
translation_state,
crate::auth::auth_translation_middleware,
))
.layer(Extension(Arc::new(GithubEndpoints::production_defaults())))
@ -1011,7 +1009,7 @@ client_id = "github-client-id"
axum::http::HeaderValue::from_str(&session_cookie).unwrap(),
);
let session = read_private_session(&cookie_headers, &key).expect("session should decode");
assert_eq!(session.auth_method, RunAuthMethod::DevToken);
assert_eq!(session.auth_method, AuthMethod::DevToken);
assert_eq!(session.v, 2);
assert_eq!(
session.identity,
@ -1051,7 +1049,7 @@ client_id = "github-client-id"
email: "octocat@example.com".to_string(),
avatar_url: String::new(),
user_url: String::new(),
auth_method: RunAuthMethod::Github,
auth_method: AuthMethod::Github,
},
chrono::Duration::minutes(10),
);

View file

@ -1,19 +1,15 @@
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use axum::extract::{FromRequestParts, Path};
use axum::http::StatusCode;
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
use fabro_types::{CommandOutputStream, RunBlobId, RunId, StageId};
use fabro_types::RunId;
use jsonwebtoken::errors::ErrorKind;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
use tracing::{debug, warn};
use tracing::warn;
use uuid::Uuid;
use crate::ApiError;
use crate::auth::{self, KeyDeriveError};
use crate::jwt_auth::{authenticate_service_parts, bearer_token};
use crate::server::{AppState, parse_blob_id_path, parse_run_id_path, parse_stage_id_path};
use crate::auth::{self, JwtError, KeyDeriveError};
pub(crate) const WORKER_TOKEN_ISSUER: &str = "fabro-server-worker";
pub(crate) const WORKER_TOKEN_SCOPE: &str = "run:worker";
@ -85,168 +81,43 @@ pub(crate) fn issue_worker_token(
})
}
pub(crate) fn authorize_worker_token(
parts: &Parts,
run_id: &RunId,
keys: &WorkerTokenKeys,
) -> Result<bool, ApiError> {
let Some(Ok(token)) = bearer_token(parts) else {
return Ok(false);
};
let claims =
match jsonwebtoken::decode::<WorkerTokenClaims>(token, &keys.decoding, &keys.validation) {
Ok(token_data) => token_data.claims,
Err(_) => return Ok(false),
};
pub(crate) fn decode_worker_token(token: &str, keys: &WorkerTokenKeys) -> Result<RunId, JwtError> {
let claims = jsonwebtoken::decode::<WorkerTokenClaims>(token, &keys.decoding, &keys.validation)
.map_err(|err| match err.kind() {
ErrorKind::ExpiredSignature => JwtError::AccessTokenExpired,
_ => JwtError::AccessTokenInvalid,
})?
.claims;
if claims.scope != WORKER_TOKEN_SCOPE {
warn!(
target: "worker_auth",
run_id = %run_id,
jti = %claims.jti,
reason = "wrong_scope",
"worker token rejected"
);
return Err(ApiError::forbidden());
}
if claims.run_id != run_id.to_string() {
warn!(
target: "worker_auth",
run_id = %run_id,
token_run_id = %claims.run_id,
jti = %claims.jti,
reason = "run_id_mismatch",
"worker token rejected"
);
return Err(ApiError::forbidden());
return Err(JwtError::AccessTokenInvalid);
}
debug!(
target: "worker_auth",
run_id = %run_id,
jti = %claims.jti,
"worker token accepted"
);
Ok(true)
}
fn authorize_run_scoped(parts: &Parts, state: &AppState, run_id: &RunId) -> Result<(), ApiError> {
if authorize_worker_token(parts, run_id, state.worker_token_keys())? {
return Ok(());
}
authenticate_service_parts(parts)
}
pub(crate) struct AuthorizeRunScoped(pub(crate) RunId);
impl FromRequestParts<Arc<AppState>> for AuthorizeRunScoped {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let Path(id): Path<String> = Path::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
let run_id = parse_run_id_path(&id)?;
authorize_run_scoped(parts, state.as_ref(), &run_id)
.map_err(IntoResponse::into_response)?;
Ok(Self(run_id))
}
}
pub(crate) struct AuthorizeRunBlob(pub(crate) RunId, pub(crate) RunBlobId);
impl FromRequestParts<Arc<AppState>> for AuthorizeRunBlob {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let Path((id, blob_id)): Path<(String, String)> = Path::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
let run_id = parse_run_id_path(&id)?;
let blob_id = parse_blob_id_path(&blob_id)?;
authorize_run_scoped(parts, state.as_ref(), &run_id)
.map_err(IntoResponse::into_response)?;
Ok(Self(run_id, blob_id))
}
}
pub(crate) struct AuthorizeStageArtifact(pub(crate) RunId, pub(crate) StageId);
impl FromRequestParts<Arc<AppState>> for AuthorizeStageArtifact {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let Path((id, stage_id)): Path<(String, String)> = Path::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
let run_id = parse_run_id_path(&id)?;
let stage_id = parse_stage_id_path(&stage_id)?;
authorize_run_scoped(parts, state.as_ref(), &run_id)
.map_err(IntoResponse::into_response)?;
Ok(Self(run_id, stage_id))
}
}
pub(crate) struct AuthorizeCommandLog(
pub(crate) RunId,
pub(crate) StageId,
pub(crate) CommandOutputStream,
);
impl FromRequestParts<Arc<AppState>> for AuthorizeCommandLog {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let Path((id, stage_id, stream)): Path<(String, String, String)> =
Path::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
let run_id = parse_run_id_path(&id)?;
let stage_id = parse_stage_id_path(&stage_id)?;
let stream = stream
.parse::<CommandOutputStream>()
.map_err(|_| ApiError::bad_request("Invalid command log stream.").into_response())?;
authorize_run_scoped(parts, state.as_ref(), &run_id)
.map_err(IntoResponse::into_response)?;
Ok(Self(run_id, stage_id, stream))
}
claims
.run_id
.parse()
.map_err(|_| JwtError::AccessTokenInvalid)
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex as StdMutex};
use axum::http::header;
use axum::http::request::Parts;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::Duration as ChronoDuration;
use jsonwebtoken::{Algorithm, Header, decode};
use serde_json::json;
use tracing::field::{Field, Visit};
use tracing::{Event, Subscriber, subscriber};
use tracing_subscriber::layer::{Context, SubscriberExt};
use tracing_subscriber::{Layer, Registry};
use uuid::Uuid;
use super::{
WORKER_TOKEN_ISSUER, WORKER_TOKEN_SCOPE, WorkerTokenClaims, WorkerTokenKeys,
authorize_worker_token, issue_worker_token,
decode_worker_token, issue_worker_token,
};
use crate::auth;
use crate::auth::{self, JwtError};
const TEST_SECRET: &[u8] = b"0123456789abcdef0123456789abcdef";
const OTHER_SECRET: &[u8] = b"fedcba9876543210fedcba9876543210";
@ -259,23 +130,6 @@ mod tests {
"01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap()
}
fn other_run_id() -> fabro_types::RunId {
"01ARZ3NDEKTSV4RRFFQ69G5FAW".parse().unwrap()
}
fn request_parts(authorization: Option<&str>) -> Parts {
let mut builder = axum::http::Request::builder();
if let Some(authorization) = authorization {
builder = builder.header(header::AUTHORIZATION, authorization);
}
let (parts, ()) = builder.body(()).unwrap().into_parts();
parts
}
fn bearer_parts(token: &str) -> Parts {
request_parts(Some(&format!("Bearer {token}")))
}
fn wrong_scope_token(keys: &WorkerTokenKeys, run_id: &fabro_types::RunId) -> String {
let claims = WorkerTokenClaims {
iss: WORKER_TOKEN_ISSUER.to_string(),
@ -324,72 +178,6 @@ mod tests {
format!("{header}.{payload}.")
}
fn issue_user_jwt() -> String {
let subject = auth::JwtSubject {
identity: fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(),
login: "octocat".to_string(),
name: "The Octocat".to_string(),
email: "octocat@example.com".to_string(),
avatar_url: "https://example.com/octocat.png".to_string(),
user_url: "https://github.com/octocat".to_string(),
auth_method: fabro_types::RunAuthMethod::Github,
};
let key = auth::derive_jwt_key(TEST_SECRET).expect("user jwt key should derive");
auth::issue(
&key,
"https://fabro.example",
&subject,
ChronoDuration::minutes(10),
)
}
#[derive(Debug)]
struct LogCapture {
target: String,
fields: Vec<(String, String)>,
}
#[derive(Default)]
struct LogCaptureVisitor {
fields: Vec<(String, String)>,
}
impl Visit for LogCaptureVisitor {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
self.fields
.push((field.name().to_string(), format!("{value:?}")));
}
}
struct LogCaptureLayer {
events: Arc<StdMutex<Vec<LogCapture>>>,
}
impl<S: Subscriber> Layer<S> for LogCaptureLayer {
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
if event.metadata().target() != "worker_auth" {
return;
}
let mut visitor = LogCaptureVisitor::default();
event.record(&mut visitor);
self.events.lock().unwrap().push(LogCapture {
target: event.metadata().target().to_string(),
fields: visitor.fields,
});
}
}
fn capture_logs<T>(f: impl FnOnce() -> T) -> (T, Arc<StdMutex<Vec<LogCapture>>>) {
let events = Arc::new(StdMutex::new(Vec::<LogCapture>::new()));
let layer = LogCaptureLayer {
events: Arc::clone(&events),
};
let subscriber = Registry::default().with(layer);
let result = subscriber::with_default(subscriber, f);
(result, events)
}
#[test]
fn issue_worker_token_round_trips_claims() {
let run_id = run_id();
@ -449,179 +237,60 @@ mod tests {
}
#[test]
fn authorize_worker_token_accepts_matching_run_id() {
fn decode_worker_token_returns_run_id() {
let run_id = run_id();
let keys = keys(TEST_SECRET);
let token = issue_worker_token(&keys, &run_id).expect("worker token should issue");
let parts = bearer_parts(&token);
assert!(authorize_worker_token(&parts, &run_id, &keys).unwrap());
assert_eq!(decode_worker_token(&token, &keys).unwrap(), run_id);
}
#[test]
fn authorize_worker_token_rejects_cross_run_reuse() {
let run_id = run_id();
let other_run_id = other_run_id();
let keys = keys(TEST_SECRET);
let token = issue_worker_token(&keys, &other_run_id).expect("worker token should issue");
let parts = bearer_parts(&token);
let err = authorize_worker_token(&parts, &run_id, &keys)
.expect_err("mismatched run should reject");
assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN);
}
#[test]
fn authorize_worker_token_rejects_wrong_scope() {
fn decode_worker_token_rejects_wrong_scope() {
let run_id = run_id();
let keys = keys(TEST_SECRET);
let token = wrong_scope_token(&keys, &run_id);
let parts = bearer_parts(&token);
let err =
authorize_worker_token(&parts, &run_id, &keys).expect_err("wrong scope should reject");
assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN);
assert_eq!(
decode_worker_token(&token, &keys).expect_err("wrong scope should reject"),
JwtError::AccessTokenInvalid,
);
}
#[test]
fn authorize_worker_token_falls_through_without_header() {
let run_id = run_id();
let keys = keys(TEST_SECRET);
let parts = request_parts(None);
let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys));
assert!(!result.unwrap());
assert!(captured.lock().unwrap().is_empty());
}
#[test]
fn authorize_worker_token_falls_through_for_user_jwt_without_worker_logs() {
let run_id = run_id();
let keys = keys(TEST_SECRET);
let token = issue_user_jwt();
let parts = bearer_parts(&token);
let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys));
assert!(!result.unwrap());
assert!(captured.lock().unwrap().is_empty());
}
#[test]
fn authorize_worker_token_falls_through_for_expired_token_without_worker_logs() {
fn decode_worker_token_rejects_expired_tokens() {
let run_id = run_id();
let keys = keys(TEST_SECRET);
let token = expired_worker_token(&keys, &run_id);
let parts = bearer_parts(&token);
let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys));
assert!(!result.unwrap());
assert!(captured.lock().unwrap().is_empty());
assert_eq!(
decode_worker_token(&token, &keys).expect_err("expired token should reject"),
JwtError::AccessTokenExpired,
);
}
#[test]
fn authorize_worker_token_falls_through_for_bad_signature_without_worker_logs() {
fn decode_worker_token_rejects_bad_signature() {
let run_id = run_id();
let signer = keys(OTHER_SECRET);
let verifier = keys(TEST_SECRET);
let token = issue_worker_token(&signer, &run_id).expect("worker token should issue");
let parts = bearer_parts(&token);
let (result, captured) =
capture_logs(|| authorize_worker_token(&parts, &run_id, &verifier));
assert!(!result.unwrap());
assert!(captured.lock().unwrap().is_empty());
assert_eq!(
decode_worker_token(&token, &verifier).expect_err("bad signature should reject"),
JwtError::AccessTokenInvalid,
);
}
#[test]
fn authorize_worker_token_falls_through_for_alg_none_without_worker_logs() {
fn decode_worker_token_rejects_alg_none() {
let run_id = run_id();
let keys = keys(TEST_SECRET);
let token = alg_none_token(&run_id);
let parts = bearer_parts(&token);
let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys));
assert!(!result.unwrap());
assert!(captured.lock().unwrap().is_empty());
}
#[test]
fn authorize_worker_token_logs_acceptance() {
let run_id = run_id();
let keys = keys(TEST_SECRET);
let token = issue_worker_token(&keys, &run_id).expect("worker token should issue");
let parts = bearer_parts(&token);
let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys));
assert!(result.unwrap());
let events = captured.lock().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].target, "worker_auth");
assert!(events[0]
.fields
.iter()
.any(|(field, value)| field == "message" && value.contains("worker token accepted")));
assert!(
events[0]
.fields
.iter()
.any(|(field, value)| field == "run_id" && value.contains(&run_id.to_string()))
);
assert!(
events[0]
.fields
.iter()
.any(|(field, value)| field == "jti" && !value.is_empty())
);
}
#[test]
fn authorize_worker_token_logs_run_id_mismatch() {
let run_id = run_id();
let other_run_id = other_run_id();
let keys = keys(TEST_SECRET);
let token = issue_worker_token(&keys, &other_run_id).expect("worker token should issue");
let parts = bearer_parts(&token);
let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys));
let err = result.expect_err("mismatched run should reject");
assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN);
let events = captured.lock().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].target, "worker_auth");
assert!(
events[0]
.fields
.iter()
.any(|(field, value)| field == "reason" && value.contains("run_id_mismatch"))
);
}
#[test]
fn authorize_worker_token_logs_wrong_scope() {
let run_id = run_id();
let keys = keys(TEST_SECRET);
let token = wrong_scope_token(&keys, &run_id);
let parts = bearer_parts(&token);
let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys));
let err = result.expect_err("wrong scope should reject");
assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN);
let events = captured.lock().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].target, "worker_auth");
assert!(
events[0]
.fields
.iter()
.any(|(field, value)| field == "reason" && value.contains("wrong_scope"))
assert_eq!(
decode_worker_token(&token, &keys).expect_err("alg none should reject"),
JwtError::AccessTokenInvalid,
);
}
}

View file

@ -9,7 +9,7 @@ use fabro_config::ServerSettingsBuilder;
use fabro_server::ip_allowlist::{IpAllowlist, IpAllowlistConfig};
use fabro_server::jwt_auth::{AuthMode, resolve_auth_mode_with_lookup};
use fabro_server::server::{
RouterOptions, build_router, build_router_with_options, create_app_state,
RouterOptions, build_router, create_app_state,
create_app_state_with_runtime_settings_and_options,
};
use tower::ServiceExt;
@ -47,7 +47,7 @@ fn spa_fixture_root() -> PathBuf {
#[tokio::test]
async fn old_unversioned_routes_return_404() {
let app = build_router(create_app_state(), AuthMode::Disabled);
let app = fabro_server::test_support::build_test_router(create_app_state());
let cases = [(Method::POST, "/completions")];
@ -64,9 +64,8 @@ async fn old_unversioned_routes_return_404() {
#[tokio::test]
async fn root_and_health_stay_at_root() {
let app = build_router_with_options(
let app = fabro_server::test_support::build_test_router_with_options(
create_app_state(),
&AuthMode::Disabled,
Arc::new(IpAllowlistConfig::default()),
RouterOptions {
static_asset_root: Some(spa_fixture_root()),
@ -99,7 +98,7 @@ async fn root_and_health_stay_at_root() {
#[tokio::test]
async fn install_routes_are_absent_in_normal_mode() {
let app = build_router(create_app_state(), AuthMode::Disabled);
let app = fabro_server::test_support::build_test_router(create_app_state());
let response = app
.oneshot(
@ -117,7 +116,7 @@ async fn install_routes_are_absent_in_normal_mode() {
#[tokio::test]
async fn moved_routes_not_at_root_of_api_prefix() {
let app = build_router(create_app_state(), AuthMode::Disabled);
let app = fabro_server::test_support::build_test_router(create_app_state());
for path in ["/api/v1/health", "/api/v1/"] {
let req = Request::builder()
@ -132,7 +131,7 @@ async fn moved_routes_not_at_root_of_api_prefix() {
#[tokio::test]
async fn source_maps_are_not_served() {
let app = build_router(create_app_state(), AuthMode::Disabled);
let app = fabro_server::test_support::build_test_router(create_app_state());
let request = Request::builder()
.method("GET")
@ -151,9 +150,10 @@ async fn source_maps_are_not_served() {
#[tokio::test]
async fn web_enabled_serves_web_only_routes() {
let app = build_router_with_options(
let auth_mode = dev_token_enabled_auth_mode();
let app = fabro_server::server::build_router_with_options(
create_app_state(),
&AuthMode::Disabled,
&auth_mode,
Arc::new(IpAllowlistConfig::default()),
RouterOptions {
static_asset_root: Some(spa_fixture_root()),
@ -228,6 +228,7 @@ async fn web_enabled_serves_web_only_routes() {
let demo_toggle_request = Request::builder()
.method("POST")
.uri("/api/v1/demo/toggle")
.header("authorization", format!("Bearer {DEV_TOKEN}"))
.header("content-type", "application/json")
.body(Body::from(r#"{"enabled":true}"#))
.unwrap();
@ -315,9 +316,8 @@ async fn toggle_demo_allows_authenticated_requests() {
#[tokio::test]
async fn security_headers_are_applied_to_all_responses() {
let app = build_router_with_options(
let app = fabro_server::test_support::build_test_router_with_options(
create_app_state(),
&AuthMode::Disabled,
Arc::new(IpAllowlistConfig::default()),
RouterOptions {
static_asset_root: Some(spa_fixture_root()),
@ -450,13 +450,12 @@ _version = 1
enabled = false
",
);
let app = build_router_with_options(
let app = fabro_server::test_support::build_test_router_with_options(
create_app_state_with_runtime_settings_and_options(
settings.server_settings,
settings.manifest_run_defaults,
5,
),
&AuthMode::Disabled,
Arc::new(IpAllowlistConfig::default()),
RouterOptions {
web_enabled: false,
@ -515,13 +514,12 @@ _version = 1
enabled = false
",
);
let app = build_router_with_options(
let app = fabro_server::test_support::build_test_router_with_options(
create_app_state_with_runtime_settings_and_options(
settings.server_settings,
settings.manifest_run_defaults,
5,
),
&AuthMode::Disabled,
Arc::new(IpAllowlistConfig::default()),
RouterOptions {
web_enabled: false,
@ -543,9 +541,8 @@ enabled = false
#[tokio::test]
async fn allowlist_blocks_non_allowlisted_api_requests() {
let app = build_router_with_options(
let app = fabro_server::test_support::build_test_router_with_options(
create_app_state(),
&AuthMode::Disabled,
Arc::new(IpAllowlistConfig {
allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]),
trusted_proxy_count: 0,
@ -566,9 +563,8 @@ async fn allowlist_blocks_non_allowlisted_api_requests() {
#[tokio::test]
async fn allowlist_exempts_health_checks() {
let app = build_router_with_options(
let app = fabro_server::test_support::build_test_router_with_options(
create_app_state(),
&AuthMode::Disabled,
Arc::new(IpAllowlistConfig {
allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]),
trusted_proxy_count: 0,

View file

@ -12,8 +12,7 @@ use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_server::jwt_auth::AuthMode;
use fabro_server::server::{build_router, create_app_state_with_store};
use fabro_server::server::create_app_state_with_store;
use fabro_store::{ArtifactStore, Database};
use fabro_types::RunId;
use fabro_workflow::event as workflow_event;
@ -83,7 +82,7 @@ async fn append_completed_run_with_final_patch(
#[tokio::test]
async fn invalid_run_id_returns_400() {
let app = build_router(test_app_state(), AuthMode::Disabled);
let app = fabro_server::test_support::build_test_router(test_app_state());
let req = Request::builder()
.method("GET")
.uri(files_url("not-a-ulid"))
@ -100,7 +99,7 @@ async fn invalid_run_id_returns_400() {
#[tokio::test]
async fn unknown_run_returns_404() {
let app = build_router(test_app_state(), AuthMode::Disabled);
let app = fabro_server::test_support::build_test_router(test_app_state());
// Valid ULID format but not a run we've created.
let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let req = Request::builder()
@ -119,7 +118,7 @@ async fn unknown_run_returns_404() {
#[tokio::test]
async fn malformed_from_sha_query_returns_400() {
let app = build_router(test_app_state(), AuthMode::Disabled);
let app = fabro_server::test_support::build_test_router(test_app_state());
let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let req = Request::builder()
.method("GET")
@ -143,7 +142,7 @@ async fn malformed_from_sha_query_returns_400() {
#[tokio::test]
async fn non_default_from_sha_returns_400_even_when_hex() {
let app = build_router(test_app_state(), AuthMode::Disabled);
let app = fabro_server::test_support::build_test_router(test_app_state());
let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
// Well-formed hex SHA but v1 reserves the parameter for a future
// version; any non-default value must be rejected.
@ -166,7 +165,7 @@ async fn non_default_from_sha_returns_400_even_when_hex() {
#[tokio::test]
async fn malformed_to_sha_returns_400() {
let app = build_router(test_app_state(), AuthMode::Disabled);
let app = fabro_server::test_support::build_test_router(test_app_state());
let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let req = Request::builder()
.method("GET")
@ -187,7 +186,7 @@ async fn submitted_run_without_sandbox_returns_empty_envelope() {
// A run that has been created but not started has no base_sha or
// sandbox record, so the handler returns an empty envelope. The UI
// maps that to R4(a).
let app = build_router(test_app_state(), AuthMode::Disabled);
let app = fabro_server::test_support::build_test_router(test_app_state());
let manifest = minimal_manifest_json(MINIMAL_DOT);
let create_req = Request::builder()
.method("POST")
@ -232,7 +231,7 @@ async fn degraded_run_returns_file_diff_shape_without_meta_patch() {
Arc::clone(&store),
artifact_store,
);
let app = build_router(state, AuthMode::Disabled);
let app = fabro_server::test_support::build_test_router(state);
let run_id = RunId::new();
let patch = "\
diff --git a/src/lib.rs b/src/lib.rs
@ -284,7 +283,7 @@ diff --git a/.env.production b/.env.production
async fn demo_mode_returns_fixture_without_touching_store() {
// R34: demo handler must return the illustrative fixture with no
// cross-contamination with real run state.
let app = build_router(test_app_state(), AuthMode::Disabled);
let app = fabro_server::test_support::build_test_router(test_app_state());
let arbitrary = "not-even-a-valid-ulid-for-run";
let req = Request::builder()
@ -314,7 +313,7 @@ async fn response_envelope_matches_openapi_paginated_run_file_list_shape() {
// Sanity check that the happy-path envelope shape matches what the
// OpenAPI spec + regenerated TS client expect. Uses demo mode so the
// test stays deterministic without running a sandbox.
let app = build_router(test_app_state(), AuthMode::Disabled);
let app = fabro_server::test_support::build_test_router(test_app_state());
let req = Request::builder()
.method("GET")
.uri(files_url("whatever"))

View file

@ -1,7 +1,5 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_server::jwt_auth::AuthMode;
use fabro_server::server::build_router;
use tower::ServiceExt;
use crate::helpers::{
@ -40,7 +38,8 @@ slug = "fabro-app"
storage_dir.path().display()
));
let app = build_router(test_app_state_with_options(settings, 5), AuthMode::Disabled);
let app =
fabro_server::test_support::build_test_router(test_app_state_with_options(settings, 5));
let mut manifest = minimal_manifest_json(MINIMAL_DOT);
manifest["configs"] = serde_json::json!([{
"type": "user",

View file

@ -1,7 +1,6 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_server::jwt_auth::AuthMode;
use fabro_server::server::{build_router, create_app_state_with_runtime_settings_and_options};
use fabro_server::server::create_app_state_with_runtime_settings_and_options;
use tower::ServiceExt;
use crate::helpers::{response_json, settings_from_toml};
@ -32,13 +31,12 @@ allowed_usernames = ["alice"]
client_id = "Iv1.abcdef"
"#,
);
let app = build_router(
let app = fabro_server::test_support::build_test_router(
create_app_state_with_runtime_settings_and_options(
settings.server_settings,
settings.manifest_run_defaults,
5,
),
AuthMode::Disabled,
);
let request = Request::builder()

View file

@ -117,10 +117,8 @@ async fn load_questions(app: &axum::Router, run_id: &str) -> serde_json::Value {
async fn get_system_info_returns_runtime_fields() {
let (_temp, settings, expected_storage_dir) = temp_storage_settings();
let configured_server_url = settings.server_settings.server.web.url.as_source();
let app = fabro_server::server::build_router(
test_app_state_with_options(settings, 5),
fabro_server::jwt_auth::AuthMode::Disabled,
);
let app =
fabro_server::test_support::build_test_router(test_app_state_with_options(settings, 5));
let request = Request::builder()
.method("GET")

View file

@ -236,7 +236,7 @@ methods = ["dev-token"]
#[tokio::test]
async fn tcp_ip_allowlist_uses_connect_info() {
let addr = start_tcp_server(
AuthMode::Disabled,
fabro_server::test_support::test_auth_mode(),
Arc::new(IpAllowlistConfig {
allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]),
trusted_proxy_count: 0,

View file

@ -5,12 +5,11 @@ use std::time::Duration;
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use fabro_config::{LocalSandboxLayer, RunLayer, RunSandboxLayer, ServerSettingsBuilder};
use fabro_server::jwt_auth::AuthMode;
use fabro_server::server::{
AppState, build_router, create_app_state,
create_app_state_with_runtime_settings_and_env_lookup,
AppState, create_app_state, create_app_state_with_runtime_settings_and_env_lookup,
create_app_state_with_runtime_settings_and_options_and_registry_factory, spawn_scheduler,
};
use fabro_server::test_support::build_test_router;
use fabro_test::{
assert_axum_status, assert_reqwest_status, expect_axum_json, expect_axum_status,
expect_axum_status_in, expect_axum_text,
@ -110,7 +109,7 @@ pub(crate) fn test_settings() -> TestAppSettings {
pub(crate) fn test_app_with_scheduler(state: Arc<AppState>) -> axum::Router {
spawn_scheduler(Arc::clone(&state));
build_router(state, AuthMode::Disabled)
build_test_router(state)
}
pub(crate) fn test_app_with_no_providers() -> axum::Router {
@ -121,7 +120,7 @@ pub(crate) fn test_app_with_no_providers() -> axum::Router {
5,
|_| None,
);
build_router(state, AuthMode::Disabled)
build_test_router(state)
}
pub(crate) fn test_app_with_mock_anthropic(mock_base_url: &str) -> axum::Router {
@ -137,7 +136,7 @@ pub(crate) fn test_app_with_mock_anthropic(mock_base_url: &str) -> axum::Router
_ => None,
},
);
build_router(state, AuthMode::Disabled)
build_test_router(state)
}
pub(crate) fn api(path: &str) -> String {

View file

@ -11,10 +11,7 @@
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use fabro_server::install::{InstallAppState, build_install_router};
use fabro_server::jwt_auth::AuthMode;
use fabro_server::server::{
build_router, create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env,
};
use fabro_server::server::create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env;
use serde_yaml::Value;
use tower::ServiceExt;
@ -79,7 +76,7 @@ fn request_for(method: &Method, uri: &str) -> Request<Body> {
#[tokio::test]
async fn all_spec_routes_are_routable() {
let spec = load_spec();
let normal_app = build_router(test_app_state(), AuthMode::Disabled);
let normal_app = fabro_server::test_support::build_test_router(test_app_state());
let install_app = build_install_router(InstallAppState::for_test("test-install-token"));
let paths = spec
@ -148,7 +145,7 @@ fn github_webhook_spec_and_sdk_describe_a_json_body() {
async fn github_webhook_spec_route_is_routable_when_webhook_secret_is_present() {
let secret = "test-webhook-secret".to_string();
let settings = test_settings();
let app = build_router(
let app = fabro_server::test_support::build_test_router(
create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env(
settings.server_settings,
settings.manifest_run_defaults,
@ -156,7 +153,6 @@ async fn github_webhook_spec_route_is_routable_when_webhook_secret_is_present()
|_| None,
&std::collections::HashMap::from([("GITHUB_APP_WEBHOOK_SECRET".to_string(), secret)]),
),
AuthMode::Disabled,
);
let response = app
@ -174,7 +170,7 @@ async fn github_webhook_spec_route_is_routable_when_webhook_secret_is_present()
#[tokio::test]
async fn install_and_normal_routes_stay_isolated() {
let spec = load_spec();
let normal_app = build_router(test_app_state(), AuthMode::Disabled);
let normal_app = fabro_server::test_support::build_test_router(test_app_state());
let install_app = build_install_router(InstallAppState::for_test("test-install-token"));
let paths = spec

View file

@ -7,8 +7,6 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_server::jwt_auth::AuthMode;
use fabro_server::server::build_router;
use tower::ServiceExt;
use super::helpers::{response_json, test_app_state};
@ -75,7 +73,7 @@ const ENDPOINTS: &[PaginatedEndpoint] = &[
#[tokio::test]
async fn paginated_endpoints_return_correct_shape() {
let state = test_app_state();
let app = build_router(state, AuthMode::Disabled);
let app = fabro_server::test_support::build_test_router(state);
for ep in ENDPOINTS {
// Large limit: paginated shape, has_more = false (all fixture items fit).

View file

@ -3,9 +3,8 @@ use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_interview::Interviewer;
use fabro_server::jwt_auth::AuthMode;
use fabro_server::server::{
build_router, create_app_state_with_runtime_settings_and_registry_factory, spawn_scheduler,
create_app_state_with_runtime_settings_and_registry_factory, spawn_scheduler,
};
use fabro_workflow::handler::HandlerRegistry;
use fabro_workflow::handler::agent::AgentHandler;
@ -126,7 +125,7 @@ async fn full_http_lifecycle_approve_and_complete() {
gate_registry,
);
spawn_scheduler(Arc::clone(&state));
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
let app = fabro_server::test_support::build_test_router(Arc::clone(&state));
// 1. Create run
let req = Request::builder()
@ -214,7 +213,7 @@ async fn full_http_lifecycle_cancel() {
gate_registry,
);
spawn_scheduler(Arc::clone(&state));
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
let app = fabro_server::test_support::build_test_router(Arc::clone(&state));
// Create and start a run that will block at the human gate
let req = Request::builder()
@ -285,7 +284,7 @@ async fn cancel_at_human_gate_persists_cancelled_terminal_event() {
gate_registry,
);
spawn_scheduler(Arc::clone(&state));
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
let app = fabro_server::test_support::build_test_router(Arc::clone(&state));
let req = Request::builder()
.method("POST")

View file

@ -131,9 +131,10 @@ pub async fn run_event_loop(
match action {
DispatchAction::SubmitAnswer(submission) => {
let submission = *submission;
debug!(
run_id = submission.run_id,
qid = submission.qid,
run_id = submission.run_id.as_str(),
qid = submission.qid.as_str(),
"Submitting answer from Slack"
);
on_submit(submission);
@ -221,6 +222,8 @@ mod tests {
"envelope_id": "env-1",
"payload": {
"type": "block_actions",
"team": { "id": "T123" },
"user": { "id": "U123", "name": "ada" },
"actions": [{
"action_id": "interview.answer",
"type": "button",
@ -234,6 +237,7 @@ mod tests {
assert_eq!(outcome, ProcessOutcome::Continue);
match action {
DispatchAction::SubmitAnswer(submission) => {
let submission = *submission;
assert_eq!(submission.run_id, "run-1");
assert_eq!(submission.qid, "q-1");
assert_eq!(submission.answer.value, AnswerValue::Yes);
@ -284,11 +288,13 @@ mod tests {
"type": "events_api",
"envelope_id": "env-50",
"payload": {
"team_id": "T123",
"event": {
"type": "message",
"text": "my answer",
"thread_ts": "1234.5678",
"user": "U123"
"user": "U123",
"user_name": "ada"
}
}
})
@ -298,6 +304,7 @@ mod tests {
assert_eq!(outcome, ProcessOutcome::Continue);
match action {
DispatchAction::SubmitAnswer(submission) => {
let submission = *submission;
assert_eq!(submission.run_id, "run-10");
assert_eq!(submission.qid, "q-10");
assert_eq!(
@ -338,6 +345,8 @@ mod tests {
"envelope_id": "env-1",
"payload": {
"type": "block_actions",
"team": { "id": "T123" },
"user": { "id": "U123", "name": "ada" },
"actions": [{
"action_id": "interview.answer",
"type": "button",

View file

@ -6,7 +6,7 @@ use crate::threads::{self, ThreadRegistry};
#[derive(Debug)]
pub enum DispatchAction {
Connected,
SubmitAnswer(SlackAnswerSubmission),
SubmitAnswer(Box<SlackAnswerSubmission>),
Reconnect,
Ignored,
}
@ -19,7 +19,7 @@ pub fn dispatch(envelope: &SocketEnvelope, thread_registry: &ThreadRegistry) ->
return DispatchAction::Ignored;
};
match interaction::parse_interaction(payload) {
Some(submission) => DispatchAction::SubmitAnswer(submission),
Some(submission) => DispatchAction::SubmitAnswer(Box::new(submission)),
None => DispatchAction::Ignored,
}
}
@ -33,17 +33,32 @@ pub fn dispatch(envelope: &SocketEnvelope, thread_registry: &ThreadRegistry) ->
let Some(question_ref) = thread_registry.resolve(&thread_ts) else {
return DispatchAction::Ignored;
};
DispatchAction::SubmitAnswer(SlackAnswerSubmission {
let Some(actor) = event_actor(payload) else {
return DispatchAction::Ignored;
};
DispatchAction::SubmitAnswer(Box::new(SlackAnswerSubmission {
run_id: question_ref.run_id,
qid: question_ref.qid,
qid: question_ref.qid,
answer: fabro_interview::Answer::text(text),
})
actor,
}))
}
SocketEventKind::Disconnect => DispatchAction::Reconnect,
SocketEventKind::Unknown => DispatchAction::Ignored,
}
}
fn event_actor(payload: &serde_json::Value) -> Option<fabro_types::Principal> {
let event = &payload["event"];
let team_id = payload["team_id"]
.as_str()
.or_else(|| event["team"].as_str())?
.to_string();
let user_id = event["user"].as_str()?.to_string();
let user_name = event["user_name"].as_str().map(str::to_string);
Some(fabro_types::Principal::slack(team_id, user_id, user_name))
}
#[cfg(test)]
mod tests {
use fabro_interview::AnswerValue;
@ -70,6 +85,8 @@ mod tests {
envelope_id: Some("env-1".to_string()),
payload: Some(serde_json::json!({
"type": "block_actions",
"team": { "id": "T123" },
"user": { "id": "U123", "name": "ada" },
"actions": [{
"action_id": "interview.answer",
"type": "button",
@ -80,6 +97,7 @@ mod tests {
let action = dispatch(&envelope, &registry);
match action {
DispatchAction::SubmitAnswer(submission) => {
let submission = *submission;
assert_eq!(submission.run_id, "run-1");
assert_eq!(submission.qid, "q-1");
assert_eq!(submission.answer.value, AnswerValue::Yes);
@ -148,17 +166,20 @@ mod tests {
envelope_type: "events_api".to_string(),
envelope_id: Some("env-5".to_string()),
payload: Some(serde_json::json!({
"team_id": "T123",
"event": {
"type": "message",
"text": "https://github.com/org/repo",
"thread_ts": "1234.5678",
"user": "U123"
"user": "U123",
"user_name": "ada"
}
})),
};
let action = dispatch(&envelope, &registry);
match action {
DispatchAction::SubmitAnswer(submission) => {
let submission = *submission;
assert_eq!(submission.run_id, "run-10");
assert_eq!(submission.qid, "q-10");
assert_eq!(

View file

@ -1,4 +1,5 @@
use fabro_interview::Answer;
use fabro_types::Principal;
use serde_json::Value;
use crate::payload::{SlackActionPayload, SlackAnswerSubmission};
@ -20,6 +21,7 @@ pub fn parse_interaction(payload: &Value) -> Option<SlackAnswerSubmission> {
let value = action["value"].as_str()?;
let routed: SlackActionPayload = serde_json::from_str(value).ok()?;
let question_ref = routed.question_ref();
let actor = interaction_actor(payload)?;
let action_type = action["type"].as_str().unwrap_or("button");
@ -48,9 +50,21 @@ pub fn parse_interaction(payload: &Value) -> Option<SlackAnswerSubmission> {
run_id: question_ref.run_id,
qid: question_ref.qid,
answer,
actor,
})
}
fn interaction_actor(payload: &Value) -> Option<Principal> {
let team_id = payload["team"]["id"].as_str()?.to_string();
let user = &payload["user"];
let user_id = user["id"].as_str()?.to_string();
let user_name = user["name"]
.as_str()
.or_else(|| user["username"].as_str())
.map(str::to_string);
Some(Principal::slack(team_id, user_id, user_name))
}
/// Extract selected checkbox values from `payload.state.values`.
fn extract_checkbox_selections(payload: &Value) -> Answer {
let selected =
@ -79,6 +93,8 @@ mod tests {
fn parse_yes_button_click() {
let payload = serde_json::json!({
"type": "block_actions",
"team": { "id": "T123" },
"user": { "id": "U123", "name": "ada" },
"actions": [{
"action_id": "interview.answer",
"type": "button",
@ -89,12 +105,22 @@ mod tests {
assert_eq!(result.run_id, "run-1");
assert_eq!(result.qid, "q-1");
assert_eq!(result.answer.value, AnswerValue::Yes);
assert_eq!(
result.actor,
fabro_types::Principal::slack(
"T123".to_string(),
"U123".to_string(),
Some("ada".to_string())
)
);
}
#[test]
fn parse_no_button_click() {
let payload = serde_json::json!({
"type": "block_actions",
"team": { "id": "T123" },
"user": { "id": "U123", "name": "ada" },
"actions": [{
"action_id": "interview.answer",
"type": "button",
@ -111,6 +137,8 @@ mod tests {
fn parse_multiple_choice_button() {
let payload = serde_json::json!({
"type": "block_actions",
"team": { "id": "T123" },
"user": { "id": "U123", "name": "ada" },
"actions": [{
"action_id": "interview.answer",
"type": "button",
@ -126,6 +154,8 @@ mod tests {
fn checkbox_toggle_is_ignored() {
let payload = serde_json::json!({
"type": "block_actions",
"team": { "id": "T123" },
"user": { "id": "U123", "name": "ada" },
"actions": [{
"action_id": "interview.select",
"type": "checkboxes",
@ -142,6 +172,8 @@ mod tests {
fn submit_button_reads_checkbox_state() {
let payload = serde_json::json!({
"type": "block_actions",
"team": { "id": "T123" },
"user": { "id": "U123", "name": "ada" },
"actions": [{
"action_id": "interview.submit",
"type": "button",
@ -173,6 +205,8 @@ mod tests {
fn submit_button_with_no_checkboxes_selected() {
let payload = serde_json::json!({
"type": "block_actions",
"team": { "id": "T123" },
"user": { "id": "U123", "name": "ada" },
"actions": [{
"action_id": "interview.submit",
"type": "button",

View file

@ -1,4 +1,5 @@
use fabro_interview::Answer;
use fabro_types::Principal;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -12,6 +13,7 @@ pub struct SlackAnswerSubmission {
pub run_id: String,
pub qid: String,
pub answer: Answer,
pub actor: Principal,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View file

@ -1280,7 +1280,7 @@ mod tests {
state
.apply_event(&test_event(
2,
EventBody::RunArchived(RunArchivedProps { actor: None }),
EventBody::RunArchived(RunArchivedProps::default()),
None,
))
.unwrap();
@ -1343,14 +1343,14 @@ mod tests {
state
.apply_event(&test_event(
2,
EventBody::RunArchived(RunArchivedProps { actor: None }),
EventBody::RunArchived(RunArchivedProps::default()),
None,
))
.unwrap();
state
.apply_event(&test_event(
3,
EventBody::RunUnarchived(RunUnarchivedProps { actor: None }),
EventBody::RunUnarchived(RunUnarchivedProps::default()),
None,
))
.unwrap();
@ -1440,7 +1440,7 @@ mod tests {
let err = state
.apply_event(&test_event(
2,
EventBody::RunArchived(RunArchivedProps { actor: None }),
EventBody::RunArchived(RunArchivedProps::default()),
None,
))
.unwrap_err();
@ -1475,7 +1475,7 @@ mod tests {
state
.apply_event(&test_event(
2,
EventBody::RunUnarchived(RunUnarchivedProps { actor: None }),
EventBody::RunUnarchived(RunUnarchivedProps::default()),
None,
))
.unwrap();

View file

@ -16,7 +16,7 @@ mod tests {
use super::EventEnvelope;
use crate::run_event::RunCompletedProps;
use crate::{
ActorRef, EventBody, ParallelBranchId, RunEvent, StageId, SuccessReason, fixtures,
EventBody, ParallelBranchId, Principal, RunEvent, StageId, SuccessReason, fixtures,
};
#[test]
@ -72,8 +72,9 @@ mod tests {
session_id: Some("ses_42".to_string()),
parent_session_id: Some("ses_root".to_string()),
tool_call_id: Some("tool_call_xyz".to_string()),
actor: Some(ActorRef::agent(
actor: Some(Principal::agent(
Some("ses_42".to_string()),
Some("ses_root".to_string()),
Some("claude-sonnet".to_string()),
)),
body: EventBody::RunCompleted(RunCompletedProps {
@ -99,8 +100,9 @@ mod tests {
assert_eq!(wire["parent_session_id"], "ses_root");
assert_eq!(wire["tool_call_id"], "tool_call_xyz");
assert_eq!(wire["actor"]["kind"], "agent");
assert_eq!(wire["actor"]["id"], "ses_42");
assert_eq!(wire["actor"]["display"], "claude-sonnet");
assert_eq!(wire["actor"]["session_id"], "ses_42");
assert_eq!(wire["actor"]["parent_session_id"], "ses_root");
assert_eq!(wire["actor"]["model"], "claude-sonnet");
assert_eq!(wire["event"], "run.completed");
let parsed: EventEnvelope = serde_json::from_value(wire).unwrap();

View file

@ -14,6 +14,7 @@ pub mod failure_signature;
pub mod graph;
pub mod interview;
pub mod outcome;
pub mod principal;
pub mod pull_request;
pub mod repository;
pub mod retro;
@ -52,6 +53,7 @@ pub use interview::{InterviewQuestionRecord, QuestionType};
pub use outcome::{
FailureCategory, FailureDetail, NodeResult, Outcome, OutcomeMeta, StageOutcome, StageState,
};
pub use principal::{AuthMethod, Principal, PrincipalLogFields, SystemActorKind, UserPrincipal};
pub use pull_request::{
PullRequestDetail, PullRequestGithubDetail, PullRequestRecord, PullRequestRef, PullRequestUser,
};
@ -61,13 +63,13 @@ pub use retro::{
OpenItemKind, Retro, RetroNarrative, SmoothnessRating, StageRetro,
};
pub use run::{
DirtyStatus, ForkSourceRef, GitContext, PreRunPushOutcome, RunAuthMethod, RunClientProvenance,
RunProvenance, RunServerProvenance, RunSpec, RunSubjectProvenance,
DirtyStatus, ForkSourceRef, GitContext, PreRunPushOutcome, RunClientProvenance, RunProvenance,
RunServerProvenance, RunSpec,
};
pub use run_blob_id::RunBlobId;
pub use run_event::{
ActorKind, ActorRef, EventBody, ExecOutputTail, InterviewOption, MetadataSnapshotFailureKind,
MetadataSnapshotPhase, RunEvent, RunNoticeLevel,
EventBody, ExecOutputTail, InterviewOption, MetadataSnapshotFailureKind, MetadataSnapshotPhase,
RunEvent, RunNoticeLevel,
};
pub use run_id::{RunId, fixtures};
pub use run_projection::{PendingInterviewRecord, RunProjection, StageProjection, first_event_seq};

View file

@ -0,0 +1,335 @@
use serde::{Deserialize, Serialize};
use crate::{IdpIdentity, RunId};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserPrincipal {
pub identity: IdpIdentity,
pub login: String,
pub auth_method: AuthMethod,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Principal {
User(UserPrincipal),
Worker {
run_id: RunId,
},
Webhook {
delivery_id: String,
},
Slack {
team_id: String,
user_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
user_name: Option<String>,
},
Agent {
#[serde(default, skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
parent_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
},
System {
system_kind: SystemActorKind,
},
Anonymous,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthMethod {
Github,
DevToken,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SystemActorKind {
Engine,
Watchdog,
Timeout,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrincipalLogFields {
pub principal_kind: &'static str,
pub user_auth_method: Option<&'static str>,
pub idp_issuer: Option<String>,
pub idp_subject: Option<String>,
pub login: Option<String>,
pub run_id: Option<String>,
pub delivery_id: Option<String>,
pub team_id: Option<String>,
pub user_id: Option<String>,
}
impl Principal {
#[must_use]
pub fn user(identity: IdpIdentity, login: String, auth_method: AuthMethod) -> Self {
Self::User(UserPrincipal {
identity,
login,
auth_method,
})
}
#[must_use]
pub fn worker(run_id: RunId) -> Self {
Self::Worker { run_id }
}
#[must_use]
pub fn webhook(delivery_id: String) -> Self {
Self::Webhook { delivery_id }
}
#[must_use]
pub fn slack(team_id: String, user_id: String, user_name: Option<String>) -> Self {
Self::Slack {
team_id,
user_id,
user_name,
}
}
#[must_use]
pub fn agent(
session_id: Option<String>,
parent_session_id: Option<String>,
model: Option<String>,
) -> Self {
Self::Agent {
session_id,
parent_session_id,
model,
}
}
#[must_use]
pub fn system(system_kind: SystemActorKind) -> Self {
Self::System { system_kind }
}
#[must_use]
pub fn anonymous() -> Self {
Self::Anonymous
}
#[must_use]
pub fn user_identity(&self) -> Option<&IdpIdentity> {
match self {
Self::User(user) => Some(&user.identity),
_ => None,
}
}
#[must_use]
pub fn display(&self) -> String {
match self {
Self::User(user) => user.login.clone(),
Self::Worker { run_id } => run_id.to_string(),
Self::Webhook { delivery_id } => delivery_id.clone(),
Self::Slack {
user_name: Some(user_name),
..
} => user_name.clone(),
Self::Slack {
team_id, user_id, ..
} => format!("{team_id}:{user_id}"),
Self::Agent {
model: Some(model), ..
} => model.clone(),
Self::Agent {
session_id: Some(session_id),
..
} => session_id.clone(),
Self::Agent { .. } => "agent".to_string(),
Self::System { system_kind } => format!("system:{system_kind:?}").to_lowercase(),
Self::Anonymous => "anonymous".to_string(),
}
}
#[must_use]
pub fn log_fields(&self) -> PrincipalLogFields {
match self {
Self::User(user) => PrincipalLogFields {
principal_kind: "user",
user_auth_method: Some(user.auth_method.as_str()),
idp_issuer: Some(user.identity.issuer().to_string()),
idp_subject: Some(user.identity.subject().to_string()),
login: Some(user.login.clone()),
run_id: None,
delivery_id: None,
team_id: None,
user_id: None,
},
Self::Worker { run_id } => PrincipalLogFields {
principal_kind: "worker",
user_auth_method: None,
idp_issuer: None,
idp_subject: None,
login: None,
run_id: Some(run_id.to_string()),
delivery_id: None,
team_id: None,
user_id: None,
},
Self::Webhook { delivery_id } => PrincipalLogFields {
principal_kind: "webhook",
user_auth_method: None,
idp_issuer: None,
idp_subject: None,
login: None,
run_id: None,
delivery_id: Some(delivery_id.clone()),
team_id: None,
user_id: None,
},
Self::Slack {
team_id, user_id, ..
} => PrincipalLogFields {
principal_kind: "slack",
user_auth_method: None,
idp_issuer: None,
idp_subject: None,
login: None,
run_id: None,
delivery_id: None,
team_id: Some(team_id.clone()),
user_id: Some(user_id.clone()),
},
Self::Agent { .. } => PrincipalLogFields {
principal_kind: "agent",
user_auth_method: None,
idp_issuer: None,
idp_subject: None,
login: None,
run_id: None,
delivery_id: None,
team_id: None,
user_id: None,
},
Self::System { .. } => PrincipalLogFields {
principal_kind: "system",
user_auth_method: None,
idp_issuer: None,
idp_subject: None,
login: None,
run_id: None,
delivery_id: None,
team_id: None,
user_id: None,
},
Self::Anonymous => PrincipalLogFields {
principal_kind: "anonymous",
user_auth_method: None,
idp_issuer: None,
idp_subject: None,
login: None,
run_id: None,
delivery_id: None,
team_id: None,
user_id: None,
},
}
}
}
impl AuthMethod {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Github => "github",
Self::DevToken => "dev_token",
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{AuthMethod, Principal, SystemActorKind};
use crate::{IdpIdentity, fixtures};
fn identity() -> IdpIdentity {
IdpIdentity::new("https://github.com", "12345").unwrap()
}
#[test]
fn user_principal_serializes_flat_with_identity() {
let principal = Principal::user(identity(), "octocat".to_string(), AuthMethod::Github);
assert_eq!(
serde_json::to_value(&principal).unwrap(),
json!({
"kind": "user",
"identity": {
"issuer": "https://github.com",
"subject": "12345"
},
"login": "octocat",
"auth_method": "github"
})
);
}
#[test]
fn system_principal_uses_system_kind_field() {
let principal = Principal::system(SystemActorKind::Watchdog);
assert_eq!(
serde_json::to_value(&principal).unwrap(),
json!({
"kind": "system",
"system_kind": "watchdog"
})
);
}
#[test]
fn round_trips_all_variants() {
let variants = [
Principal::user(identity(), "octocat".to_string(), AuthMethod::Github),
Principal::worker(fixtures::RUN_1),
Principal::webhook("delivery-1".to_string()),
Principal::slack("T1".to_string(), "U1".to_string(), Some("ada".to_string())),
Principal::agent(
Some("session".to_string()),
Some("parent".to_string()),
Some("gpt".to_string()),
),
Principal::system(SystemActorKind::Engine),
Principal::anonymous(),
];
for principal in variants {
let value = serde_json::to_value(&principal).unwrap();
let parsed: Principal = serde_json::from_value(value).unwrap();
assert_eq!(parsed, principal);
}
}
#[test]
fn projects_log_fields() {
let user = Principal::user(identity(), "octocat".to_string(), AuthMethod::DevToken);
let fields = user.log_fields();
assert_eq!(fields.principal_kind, "user");
assert_eq!(fields.user_auth_method, Some("dev_token"));
assert_eq!(fields.idp_issuer.as_deref(), Some("https://github.com"));
assert_eq!(fields.idp_subject.as_deref(), Some("12345"));
assert_eq!(fields.login.as_deref(), Some("octocat"));
let worker = Principal::worker(fixtures::RUN_1);
assert_eq!(worker.log_fields().principal_kind, "worker");
assert_eq!(
worker.log_fields().run_id,
Some(fixtures::RUN_1.to_string())
);
}
}

View file

@ -4,17 +4,10 @@ use serde::{Deserialize, Serialize};
use crate::WorkflowSettings;
use crate::graph::Graph;
use crate::principal::Principal;
use crate::run_blob_id::RunBlobId;
use crate::run_id::RunId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunAuthMethod {
Disabled,
DevToken,
Github,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunServerProvenance {
pub version: String,
@ -30,13 +23,6 @@ pub struct RunClientProvenance {
pub version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunSubjectProvenance {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub login: Option<String>,
pub auth_method: RunAuthMethod,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunProvenance {
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -44,7 +30,7 @@ pub struct RunProvenance {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<RunClientProvenance>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<RunSubjectProvenance>,
pub subject: Option<Principal>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]

View file

@ -16,7 +16,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::{Map, Value, json};
pub use stage::*;
use crate::{ParallelBranchId, RunId, StageId};
use crate::{ParallelBranchId, Principal, RunId, StageId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@ -26,52 +26,6 @@ pub enum RunNoticeLevel {
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActorKind {
User,
Agent,
System,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActorRef {
pub kind: ActorKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display: Option<String>,
}
impl ActorRef {
#[must_use]
pub fn user(login: String) -> Self {
Self {
kind: ActorKind::User,
id: Some(login.clone()),
display: Some(login),
}
}
#[must_use]
pub fn agent(session_id: Option<String>, display: Option<String>) -> Self {
Self {
kind: ActorKind::Agent,
id: session_id,
display,
}
}
#[must_use]
pub fn system_worker() -> Self {
Self {
kind: ActorKind::System,
id: Some("worker".to_string()),
display: Some("system:worker".to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RunEvent {
pub id: String,
@ -85,7 +39,7 @@ pub struct RunEvent {
pub session_id: Option<String>,
pub parent_session_id: Option<String>,
pub tool_call_id: Option<String>,
pub actor: Option<ActorRef>,
pub actor: Option<Principal>,
pub body: EventBody,
}
@ -352,7 +306,7 @@ struct RunEventRaw {
#[serde(default)]
tool_call_id: Option<String>,
#[serde(default)]
actor: Option<ActorRef>,
actor: Option<Principal>,
event: String,
#[serde(default = "default_properties")]
properties: Value,
@ -374,7 +328,7 @@ struct RunEventParts<'a> {
session_id: Option<String>,
parent_session_id: Option<String>,
tool_call_id: Option<String>,
actor: Option<ActorRef>,
actor: Option<Principal>,
event: &'a str,
properties: &'a Value,
}
@ -821,7 +775,17 @@ mod tests {
use serde_json::json;
use super::*;
use crate::{Edge, Graph, Node, RunBlobId, WorkflowSettings, fixtures};
use crate::{
AuthMethod, Edge, Graph, IdpIdentity, Node, RunBlobId, WorkflowSettings, fixtures,
};
fn user_principal(login: &str) -> Principal {
Principal::user(
IdpIdentity::new("https://github.com", "12345").unwrap(),
login.to_string(),
AuthMethod::Github,
)
}
#[test]
fn run_event_round_trips_json() {
@ -1029,8 +993,9 @@ mod tests {
"tool_call_id": "call_1",
"actor": {
"kind": "agent",
"id": "ses_child",
"display": "claude-sonnet"
"session_id": "ses_child",
"parent_session_id": "ses_parent",
"model": "claude-sonnet"
},
"properties": {
"tool_name": "read_file",
@ -1050,9 +1015,14 @@ mod tests {
);
assert_eq!(parsed.tool_call_id.as_deref(), Some("call_1"));
let actor = parsed.actor.as_ref().expect("actor present");
assert_eq!(actor.kind, ActorKind::Agent);
assert_eq!(actor.id.as_deref(), Some("ses_child"));
assert_eq!(actor.display.as_deref(), Some("claude-sonnet"));
assert_eq!(
actor,
&Principal::agent(
Some("ses_child".to_string()),
Some("ses_parent".to_string()),
Some("claude-sonnet".to_string()),
)
);
let serialized = parsed.to_value().unwrap();
assert_eq!(serialized["stage_id"], value["stage_id"]);
@ -1138,19 +1108,16 @@ mod tests {
}
#[test]
fn run_archived_serializes_with_dotted_event_name_and_actor_property() {
let body = EventBody::RunArchived(RunArchivedProps {
actor: Some(ActorRef::user("alice".to_string())),
});
fn run_archived_serializes_with_dotted_event_name_without_actor_property() {
let body = EventBody::RunArchived(RunArchivedProps::default());
let value = serde_json::to_value(&body).unwrap();
assert_eq!(value["event"], "run.archived");
assert_eq!(value["properties"]["actor"]["kind"], "user");
assert_eq!(value["properties"]["actor"]["id"], "alice");
assert_eq!(value["properties"], json!({}));
}
#[test]
fn run_unarchived_serializes_with_actor_only() {
let body = EventBody::RunUnarchived(RunUnarchivedProps { actor: None });
fn run_unarchived_serializes_without_actor_property() {
let body = EventBody::RunUnarchived(RunUnarchivedProps::default());
let value = serde_json::to_value(&body).unwrap();
assert_eq!(value["event"], "run.unarchived");
assert_eq!(value["properties"], json!({}));
@ -1163,23 +1130,25 @@ mod tests {
"ts": "2026-04-19T12:00:00.000Z",
"run_id": fixtures::RUN_1,
"event": "run.archived",
"properties": {
"actor": {
"kind": "user",
"id": "alice",
"display": "alice"
}
}
"actor": {
"kind": "user",
"identity": {
"issuer": "https://github.com",
"subject": "12345"
},
"login": "alice",
"auth_method": "github"
},
"properties": {}
});
let parsed = RunEvent::from_value(value.clone()).unwrap();
assert!(matches!(parsed.body, EventBody::RunArchived(_)));
assert_eq!(parsed.actor, Some(user_principal("alice")));
let serialized = parsed.to_value().unwrap();
assert_eq!(serialized["event"], "run.archived");
assert_eq!(
serialized["properties"]["actor"],
value["properties"]["actor"]
);
assert_eq!(serialized["actor"], value["actor"]);
assert_eq!(serialized["properties"], json!({}));
}
#[test]
@ -1194,9 +1163,7 @@ mod tests {
let parsed = RunEvent::from_value(value.clone()).unwrap();
match &parsed.body {
EventBody::RunUnarchived(props) => {
assert!(props.actor.is_none());
}
EventBody::RunUnarchived(_) => {}
other => panic!("expected RunUnarchived body, got {other:?}"),
}
}

View file

@ -2,7 +2,7 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::{ActorRef, BilledTokenCounts, RunNoticeLevel};
use super::{BilledTokenCounts, RunNoticeLevel};
use crate::status::{BlockedReason, FailureReason, SuccessReason};
use crate::{
ForkSourceRef, GitContext, Graph, RunBlobId, RunControlAction, RunId, RunProvenance,
@ -98,17 +98,19 @@ pub struct RunSupersededByProps {
pub target_visit: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunArchivedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor: Option<ActorRef>,
}
#[allow(
clippy::empty_structs_with_brackets,
reason = "This type must serialize as {} rather than null."
)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RunArchivedProps {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunUnarchivedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor: Option<ActorRef>,
}
#[allow(
clippy::empty_structs_with_brackets,
reason = "This type must serialize as {} rather than null."
)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RunUnarchivedProps {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunCompletedProps {

View file

@ -5,8 +5,8 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use ::fabro_types::{
ActorRef, BilledTokenCounts, BlockedReason, CommandTermination, FailureReason, ForkSourceRef,
GitContext, ParallelBranchId, PullRequestRecord, RunBlobId, RunControlAction, RunEvent, RunId,
BilledTokenCounts, BlockedReason, CommandTermination, FailureReason, ForkSourceRef, GitContext,
ParallelBranchId, Principal, PullRequestRecord, RunBlobId, RunControlAction, RunEvent, RunId,
RunProvenance, StageId, StageOutcome, SuccessReason, run_event as fabro_types,
};
use anyhow::{Context, Result};
@ -91,15 +91,15 @@ pub enum Event {
RunRemoving,
RunCancelRequested {
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<ActorRef>,
actor: Option<Principal>,
},
RunPauseRequested {
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<ActorRef>,
actor: Option<Principal>,
},
RunUnpauseRequested {
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<ActorRef>,
actor: Option<Principal>,
},
RunPaused,
RunUnpaused,
@ -111,11 +111,11 @@ pub enum Event {
},
RunArchived {
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<ActorRef>,
actor: Option<Principal>,
},
RunUnarchived {
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<ActorRef>,
actor: Option<Principal>,
},
WorkflowRunCompleted {
duration_ms: u64,
@ -275,18 +275,24 @@ pub enum Event {
context_display: Option<String>,
},
InterviewCompleted {
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
question_id: String,
question: String,
answer: String,
duration_ms: u64,
},
InterviewTimeout {
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
question_id: String,
question: String,
stage: String,
duration_ms: u64,
},
InterviewInterrupted {
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
question_id: String,
question: String,
stage: String,
@ -1434,7 +1440,7 @@ struct StoredEventFields {
parallel_group_id: Option<StageId>,
parallel_branch_id: Option<ParallelBranchId>,
tool_call_id: Option<String>,
actor: Option<ActorRef>,
actor: Option<Principal>,
}
fn default_node_label(node_id: Option<&String>, node_label: Option<String>) -> Option<String> {
@ -1508,7 +1514,8 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields {
| Event::RunPauseRequested { actor }
| Event::RunUnpauseRequested { actor }
| Event::RunArchived { actor }
| Event::RunUnarchived { actor, .. } => StoredEventFields {
| Event::RunUnarchived { actor, .. }
| Event::InterviewCompleted { actor, .. } => StoredEventFields {
actor: actor.clone(),
..StoredEventFields::default()
},
@ -1557,7 +1564,11 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields {
let node_label = default_node_label(node_id.as_ref(), None);
let stage_id = Some(StageId::new(stage.clone(), *visit));
let tool_call_id = agent_tool_call_id(agent_event).map(str::to_string);
let actor = agent_actor_for_event(agent_event, session_id.as_deref());
let actor = agent_actor_for_event(
agent_event,
session_id.as_deref(),
parent_session_id.as_deref(),
);
StoredEventFields {
session_id: session_id.clone(),
parent_session_id: parent_session_id.clone(),
@ -1594,21 +1605,20 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields {
}
Event::Prompt { stage, .. }
| Event::InterviewStarted { stage, .. }
| Event::InterviewTimeout { stage, .. }
| Event::InterviewInterrupted { stage, .. }
| Event::Failover { stage, .. } => node_stored_fields(Some(stage.clone())),
Event::InterviewTimeout { actor, stage, .. }
| Event::InterviewInterrupted { actor, stage, .. } => {
let mut fields = node_stored_fields(Some(stage.clone()));
fields.actor.clone_from(actor);
fields
}
Event::StallWatchdogTimeout { node, .. } => node_stored_fields(Some(node.clone())),
_ => StoredEventFields::default(),
}
}
fn actor_from_provenance(provenance: &RunProvenance) -> Option<ActorRef> {
provenance
.subject
.as_ref()?
.login
.clone()
.map(ActorRef::user)
fn actor_from_provenance(provenance: &RunProvenance) -> Option<Principal> {
provenance.subject.clone()
}
fn agent_tool_call_id(event: &AgentEvent) -> Option<&str> {
@ -1619,10 +1629,15 @@ fn agent_tool_call_id(event: &AgentEvent) -> Option<&str> {
}
}
fn agent_actor_for_event(event: &AgentEvent, session_id: Option<&str>) -> Option<ActorRef> {
fn agent_actor_for_event(
event: &AgentEvent,
session_id: Option<&str>,
parent_session_id: Option<&str>,
) -> Option<Principal> {
match event {
AgentEvent::AssistantMessage { model, .. } => Some(ActorRef::agent(
AgentEvent::AssistantMessage { model, .. } => Some(Principal::agent(
session_id.map(str::to_string),
parent_session_id.map(str::to_string),
Some(model.clone()),
)),
_ => None,
@ -1731,13 +1746,11 @@ fn event_body_from_event(event: &Event) -> EventBody {
target_node_id: target_node_id.clone(),
target_visit: *target_visit,
}),
Event::RunArchived { actor } => EventBody::RunArchived(fabro_types::RunArchivedProps {
actor: actor.clone(),
}),
Event::RunUnarchived { actor } => {
EventBody::RunUnarchived(fabro_types::RunUnarchivedProps {
actor: actor.clone(),
})
Event::RunArchived { .. } => {
EventBody::RunArchived(fabro_types::RunArchivedProps::default())
}
Event::RunUnarchived { .. } => {
EventBody::RunUnarchived(fabro_types::RunUnarchivedProps::default())
}
Event::WorkflowRunCompleted {
duration_ms,
@ -1962,6 +1975,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
context_display: context_display.clone(),
}),
Event::InterviewCompleted {
actor: _,
question_id,
question,
answer,
@ -1973,6 +1987,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
duration_ms: *duration_ms,
}),
Event::InterviewTimeout {
actor: _,
question_id,
question,
stage,
@ -1984,6 +1999,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
duration_ms: *duration_ms,
}),
Event::InterviewInterrupted {
actor: _,
question_id,
question,
stage,
@ -3172,10 +3188,18 @@ impl Emitter {
mod tests {
use std::sync::{Arc, Mutex};
use ::fabro_types::{ActorKind, fixtures};
use ::fabro_types::{AuthMethod, IdpIdentity, fixtures};
use super::*;
fn user_principal(login: &str) -> Principal {
Principal::user(
IdpIdentity::new("https://github.com", "12345").unwrap(),
login.to_string(),
AuthMethod::Github,
)
}
#[test]
fn event_emitter_new_has_no_listeners() {
let emitter = Emitter::new(fixtures::RUN_1);
@ -3483,7 +3507,7 @@ mod tests {
let second_events = Arc::clone(&second);
let sink = RunEventSink::map(
|mut event| {
event.actor = Some(ActorRef::user("alice".to_string()));
event.actor = Some(user_principal("alice"));
event
},
RunEventSink::fanout(vec![
@ -3511,8 +3535,8 @@ mod tests {
let second = second.lock().await;
assert_eq!(first.len(), 1);
assert_eq!(second.len(), 1);
assert_eq!(first[0].actor, Some(ActorRef::user("alice".to_string())));
assert_eq!(second[0].actor, Some(ActorRef::user("alice".to_string())));
assert_eq!(first[0].actor, Some(user_principal("alice")));
assert_eq!(second[0].actor, Some(user_principal("alice")));
}
#[tokio::test]
@ -3767,11 +3791,7 @@ mod tests {
#[test]
fn control_action_events_carry_actor_in_envelope() {
let actor = ActorRef {
kind: ActorKind::User,
id: Some("alice".to_string()),
display: Some("alice".to_string()),
};
let actor = user_principal("alice");
let cancel = to_run_event(&fixtures::RUN_1, &Event::RunCancelRequested {
actor: Some(actor.clone()),
@ -3804,11 +3824,7 @@ mod tests {
#[test]
fn run_archived_round_trips_actor_in_envelope() {
let actor = ActorRef {
kind: ActorKind::User,
id: Some("alice".to_string()),
display: Some("alice".to_string()),
};
let actor = user_principal("alice");
let archived = to_run_event(&fixtures::RUN_1, &Event::RunArchived {
actor: Some(actor.clone()),
@ -3820,11 +3836,7 @@ mod tests {
#[test]
fn run_unarchived_round_trips_actor_in_envelope() {
let actor = ActorRef {
kind: ActorKind::User,
id: Some("bob".to_string()),
display: Some("bob".to_string()),
};
let actor = user_principal("bob");
let unarchived = to_run_event(&fixtures::RUN_1, &Event::RunUnarchived {
actor: Some(actor.clone()),
@ -3832,9 +3844,7 @@ mod tests {
assert_eq!(unarchived.event_name(), "run.unarchived");
assert_eq!(unarchived.actor.as_ref().expect("actor set"), &actor);
match &unarchived.body {
EventBody::RunUnarchived(props) => {
assert_eq!(props.actor.as_ref().expect("actor set"), &actor);
}
EventBody::RunUnarchived(_) => {}
other => panic!("expected RunUnarchived body, got {other:?}"),
}
}
@ -3956,24 +3966,24 @@ mod tests {
parent_session_id: None,
});
let actor = stored.actor.as_ref().expect("actor set");
assert_eq!(actor.kind, ActorKind::Agent);
assert_eq!(actor.id.as_deref(), Some("ses_agent"));
assert_eq!(actor.display.as_deref(), Some("claude-sonnet"));
assert_eq!(
actor,
&Principal::agent(
Some("ses_agent".to_string()),
None,
Some("claude-sonnet".to_string()),
)
);
}
#[test]
fn run_created_populates_user_actor_from_provenance() {
use ::fabro_types::{
Graph, RunAuthMethod, RunSubjectProvenance, WorkflowSettings, fixtures,
};
use ::fabro_types::{Graph, WorkflowSettings, fixtures};
let provenance = RunProvenance {
server: None,
client: None,
subject: Some(RunSubjectProvenance {
login: Some("alice".to_string()),
auth_method: RunAuthMethod::Github,
}),
subject: Some(user_principal("alice")),
};
let stored = to_run_event(&fixtures::RUN_1, &Event::RunCreated {
@ -3994,8 +4004,6 @@ mod tests {
in_place: false,
});
let actor = stored.actor.as_ref().expect("actor set");
assert_eq!(actor.kind, ActorKind::User);
assert_eq!(actor.id.as_deref(), Some("alice"));
assert_eq!(actor.display.as_deref(), Some("alice"));
assert_eq!(actor, &user_principal("alice"));
}
}

View file

@ -6,7 +6,7 @@ use std::time::Instant;
use async_trait::async_trait;
use fabro_graphviz::graph::{Graph, Node};
use fabro_interview::{Answer, AnswerValue, Interviewer, Question};
use fabro_types::{BlockedReason, InterviewOption, QuestionType};
use fabro_types::{BlockedReason, InterviewOption, Principal, QuestionType, SystemActorKind};
use ulid::Ulid;
use super::{EngineServices, Handler};
@ -284,13 +284,16 @@ impl Handler for HumanHandler {
self.tracker
.interview_started(services.run.emitter.as_ref());
let interview_start = Instant::now();
let answer = self.interviewer.ask(question).await;
let answer_submission = self.interviewer.ask(question).await;
let answer_actor = answer_submission.actor.clone();
let answer = answer_submission.answer;
// 4. Handle timeout
if answer.value == AnswerValue::Timeout {
self.emit(
&services.run.emitter,
&Event::InterviewTimeout {
actor: Some(Principal::system(SystemActorKind::Timeout)),
question_id: question_id.clone(),
question: question_text,
stage: node.id.clone(),
@ -331,6 +334,7 @@ impl Handler for HumanHandler {
self.emit(
&services.run.emitter,
&Event::InterviewInterrupted {
actor: Some(Principal::system(SystemActorKind::Engine)),
question_id: question_id.clone(),
question: question_text,
stage: node.id.clone(),
@ -349,6 +353,7 @@ impl Handler for HumanHandler {
self.emit(
&services.run.emitter,
&Event::InterviewCompleted {
actor: Some(answer_actor),
question_id,
question: question_text,
answer: answer_text(&answer),
@ -365,6 +370,7 @@ impl Handler for HumanHandler {
self.emit(
&services.run.emitter,
&Event::InterviewCompleted {
actor: Some(answer_actor),
question_id,
question: question_text,
answer: answer_text(&answer),
@ -548,7 +554,7 @@ mod tests {
#[tokio::test]
async fn wait_human_auto_approve_selects_first() {
let interviewer = Arc::new(AutoApproveInterviewer);
let interviewer = Arc::new(AutoApproveInterviewer::engine());
let handler = HumanHandler::new(interviewer);
let graph = build_graph_with_human_gate();
let node = graph.nodes.get("gate").unwrap();
@ -570,7 +576,7 @@ mod tests {
#[tokio::test]
async fn wait_human_no_edges_returns_fail() {
let interviewer = Arc::new(AutoApproveInterviewer);
let interviewer = Arc::new(AutoApproveInterviewer::engine());
let handler = HumanHandler::new(interviewer);
let mut graph = Graph::new("test");
let gate = Node::new("gate");
@ -838,7 +844,7 @@ mod tests {
#[tokio::test]
async fn simulate_selects_first_choice() {
let interviewer = Arc::new(AutoApproveInterviewer);
let interviewer = Arc::new(AutoApproveInterviewer::engine());
let handler = HumanHandler::new(interviewer);
let graph = build_graph_with_human_gate();
let node = graph.nodes.get("gate").unwrap();

View file

@ -1,5 +1,5 @@
use fabro_store::Database;
use fabro_types::{ActorRef, RunId, RunStatus, TerminalStatus};
use fabro_types::{Principal, RunId, RunStatus, TerminalStatus};
use super::run_store::map_open_run_error;
use crate::error::Error;
@ -47,7 +47,7 @@ pub enum UnarchiveOutcome {
pub async fn archive(
store: &Database,
run_id: &RunId,
actor: Option<ActorRef>,
actor: Option<Principal>,
) -> Result<ArchiveOutcome, Error> {
let run_store = store
.open_run(run_id)
@ -94,7 +94,7 @@ pub async fn archive(
pub async fn unarchive(
store: &Database,
run_id: &RunId,
actor: Option<ActorRef>,
actor: Option<Principal>,
) -> Result<UnarchiveOutcome, Error> {
let run_store = store
.open_run(run_id)

View file

@ -1087,10 +1087,11 @@ mod tests {
name: Some("fabro-cli".to_string()),
version: Some("0.9.0".to_string()),
}),
subject: Some(fabro_types::RunSubjectProvenance {
login: None,
auth_method: fabro_types::RunAuthMethod::Disabled,
}),
subject: Some(fabro_types::Principal::user(
fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(),
"octocat".to_string(),
fabro_types::AuthMethod::Github,
)),
}),
configured_providers: Vec::new(),
},
@ -1110,8 +1111,12 @@ mod tests {
Some("fabro-cli")
);
assert_eq!(
provenance.subject.unwrap().auth_method,
fabro_types::RunAuthMethod::Disabled
provenance.subject.unwrap(),
fabro_types::Principal::user(
fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(),
"octocat".to_string(),
fabro_types::AuthMethod::Github,
)
);
}
}

View file

@ -1,5 +1,5 @@
use fabro_store::Database;
use fabro_types::{ActorRef, RunId};
use fabro_types::{Principal, RunId};
use tracing::error;
use super::archive;
@ -32,7 +32,7 @@ pub enum RewindOutcome {
pub async fn rewind(
store: &Database,
input: &RewindInput,
actor: Option<ActorRef>,
actor: Option<Principal>,
) -> Result<RewindOutcome, Error> {
let projection = store
.open_run(&input.run_id)

View file

@ -405,7 +405,7 @@ impl RunSession {
let interviewer: Arc<dyn Interviewer> = if resolved.execution.approval == ApprovalMode::Auto
{
Arc::new(AutoApproveInterviewer)
Arc::new(AutoApproveInterviewer::engine())
} else {
services.interviewer
};
@ -1107,7 +1107,7 @@ mod tests {
run_id: fixtures::RUN_1,
cancel_token: None,
emitter,
interviewer: Arc::new(fabro_interview::AutoApproveInterviewer),
interviewer: Arc::new(fabro_interview::AutoApproveInterviewer::engine()),
run_store: store.open_run(&fixtures::RUN_1).await.unwrap().into(),
event_sink: RunEventSink::store(store.open_run(&fixtures::RUN_1).await.unwrap()),
artifact_sink: None,

View file

@ -212,7 +212,7 @@ async fn execute_test_run_with_options(
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer),
interviewer: Arc::new(AutoApproveInterviewer::engine()),
lifecycle: LifecycleOptions {
setup_commands: vec![],
setup_command_timeout_ms: 1_000,
@ -270,7 +270,7 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer),
interviewer: Arc::new(AutoApproveInterviewer::engine()),
lifecycle: LifecycleOptions {
setup_commands: vec![],
setup_command_timeout_ms: 1_000,
@ -342,7 +342,7 @@ async fn run_with_lifecycle(
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer),
interviewer: Arc::new(AutoApproveInterviewer::engine()),
lifecycle,
run_options,
workflow_path: None,

View file

@ -921,7 +921,7 @@ mod tests {
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer),
interviewer: Arc::new(AutoApproveInterviewer::engine()),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: vec![command.to_string()],
setup_command_timeout_ms: 1_000,
@ -976,7 +976,7 @@ mod tests {
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer),
interviewer: Arc::new(AutoApproveInterviewer::engine()),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: vec![],
setup_command_timeout_ms: 1_000,
@ -1038,7 +1038,7 @@ mod tests {
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer),
interviewer: Arc::new(AutoApproveInterviewer::engine()),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: vec![],
setup_command_timeout_ms: 1_000,
@ -1122,7 +1122,7 @@ mod tests {
mcp_servers: Vec::new(),
dry_run: false,
},
Arc::new(AutoApproveInterviewer),
Arc::new(AutoApproveInterviewer::engine()),
&HashMap::new(),
&graph,
Arc::new(VaultCredentialSource::new(Arc::clone(&vault))),
@ -1167,7 +1167,7 @@ mod tests {
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer),
interviewer: Arc::new(AutoApproveInterviewer::engine()),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: vec!["true".to_string()],
setup_command_timeout_ms: 1_000,
@ -1279,7 +1279,7 @@ mod tests {
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer),
interviewer: Arc::new(AutoApproveInterviewer::engine()),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: vec!["sleep 5".to_string()],
setup_command_timeout_ms: 5_000,
@ -1340,7 +1340,7 @@ mod tests {
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer),
interviewer: Arc::new(AutoApproveInterviewer::engine()),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: vec![],
setup_command_timeout_ms: 5_000,

View file

@ -2375,7 +2375,7 @@ async fn tool_handler_e2e() {
graph.edges.push(Edge::new("echo_task", "exit"));
let dir = tempfile::tempdir().unwrap();
let interviewer = Arc::new(AutoApproveInterviewer);
let interviewer = Arc::new(AutoApproveInterviewer::engine());
let engine = WorkflowRunner::new(
make_full_registry(interviewer),
Arc::new(Emitter::default()),
@ -2446,7 +2446,7 @@ async fn auto_approve_interviewer_e2e() {
graph.edges.push(Edge::new("reject", "exit"));
let dir = tempfile::tempdir().unwrap();
let interviewer = Arc::new(AutoApproveInterviewer);
let interviewer = Arc::new(AutoApproveInterviewer::engine());
let engine = WorkflowRunner::new(
make_full_registry(interviewer),
Arc::new(Emitter::default()),
@ -2739,7 +2739,7 @@ async fn scenario_ship_a_feature() {
"Plan to achieve: Ship the widget"
);
let interviewer = Arc::new(AutoApproveInterviewer);
let interviewer = Arc::new(AutoApproveInterviewer::engine());
let dir = tempfile::tempdir().unwrap();
let emitter = Emitter::default();
let events = collect_events(&emitter);
@ -2813,7 +2813,9 @@ async fn scenario_parallel_expert_review() {
let graph = parse(input).expect("parse");
validate_or_raise(&graph, &[]).expect("validate");
let recorder = Arc::new(RecordingInterviewer::new(Box::new(AutoApproveInterviewer)));
let recorder = Arc::new(RecordingInterviewer::new(Box::new(
AutoApproveInterviewer::engine(),
)));
let dir = tempfile::tempdir().unwrap();
let interviewer: Arc<dyn Interviewer> = recorder.clone();
@ -3832,7 +3834,7 @@ async fn integration_smoke_plan_implement_review_done() {
assert_eq!(graph.nodes["plan"].model(), Some("test-model"));
// Run pipeline
let interviewer = Arc::new(AutoApproveInterviewer);
let interviewer = Arc::new(AutoApproveInterviewer::engine());
let dir = tempfile::tempdir().unwrap();
let emitter = Emitter::default();
let events = collect_events(&emitter);
@ -6605,7 +6607,7 @@ mod real_llm {
graph.edges.push(Edge::new("revise", "gate"));
let dir = tempfile::tempdir().unwrap();
let interviewer = Arc::new(AutoApproveInterviewer);
let interviewer = Arc::new(AutoApproveInterviewer::engine());
let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(Some(
make_llm_backend(Arc::clone(&client)),
@ -13278,7 +13280,7 @@ async fn wait_timer_e2e() {
graph.edges.push(Edge::new("wait60", "exit"));
let dir = tempfile::tempdir().unwrap();
let interviewer = Arc::new(AutoApproveInterviewer);
let interviewer = Arc::new(AutoApproveInterviewer::engine());
let engine = WorkflowRunner::new(
make_full_registry(interviewer),
Arc::new(Emitter::default()),

View file

@ -18,8 +18,6 @@ base.ts
common.ts
configuration.ts
index.ts
models/actor-kind.ts
models/actor-ref.ts
models/agent-permissions.ts
models/aggregate-billing-totals.ts
models/aggregate-billing.ts
@ -33,6 +31,7 @@ models/artifact-entry.ts
models/artifact-list-response.ts
models/artifacts-settings.ts
models/assistant-stage-turn.ts
models/auth-method.ts
models/billed-token-counts.ts
models/billing-by-model.ts
models/billing-stage-ref.ts
@ -99,6 +98,7 @@ models/health-response.ts
models/history-entry.ts
models/hook-definition.ts
models/hook-event.ts
models/idp-identity.ts
models/index.ts
models/install-finish-response.ts
models/install-github-app-manifest-input.ts
@ -185,6 +185,14 @@ models/preflight-response.ts
models/preflight-workflow-summary.ts
models/preview-url-request.ts
models/preview-url-response.ts
models/principal-agent.ts
models/principal-anonymous.ts
models/principal-slack.ts
models/principal-system.ts
models/principal-user.ts
models/principal-webhook.ts
models/principal-worker.ts
models/principal.ts
models/project-namespace.ts
models/provider.ts
models/prune-run-entry.ts
@ -293,6 +301,7 @@ models/stage-turn.ts
models/start-run-request.ts
models/submit-answer-request.ts
models/success-reason.ts
models/system-actor-kind.ts
models/system-features.ts
models/system-info-response.ts
models/system-run-counts.ts

View file

@ -1,36 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { ActorKind } from './actor-kind';
/**
* Optional primary actor associated with a run event. Present on control actions and durable agent output where a stable user or agent identity matters; omitted on routine runtime lifecycle events.
*/
export interface ActorRef {
'kind': ActorKind;
/**
* Stable actor identifier when available.
*/
'id'?: string;
/**
* Display-friendly label for the actor.
*/
'display'?: string;
}

View file

@ -15,16 +15,15 @@
/**
* High-level category of an event actor.
* Runtime user authentication method.
*/
export const ActorKind = {
USER: 'user',
AGENT: 'agent',
SYSTEM: 'system'
export const AuthMethod = {
GITHUB: 'github',
DEV_TOKEN: 'dev_token'
} as const;
export type ActorKind = typeof ActorKind[keyof typeof ActorKind];
export type AuthMethod = typeof AuthMethod[keyof typeof AuthMethod];

View file

@ -15,10 +15,10 @@
// May contain unused imports in some cases
// @ts-ignore
import type { ActorRef } from './actor-ref';
import type { EventSeq } from './event-seq';
// May contain unused imports in some cases
// @ts-ignore
import type { EventSeq } from './event-seq';
import type { Principal } from './principal';
// May contain unused imports in some cases
// @ts-ignore
import type { RunEvent } from './run-event';

View file

@ -0,0 +1,20 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface IdpIdentity {
'issuer': string;
'subject': string;
}

View file

@ -1,5 +1,3 @@
export * from './actor-kind';
export * from './actor-ref';
export * from './agent-permissions';
export * from './aggregate-billing';
export * from './aggregate-billing-totals';
@ -13,6 +11,7 @@ export * from './artifact-entry';
export * from './artifact-list-response';
export * from './artifacts-settings';
export * from './assistant-stage-turn';
export * from './auth-method';
export * from './billed-token-counts';
export * from './billing-by-model';
export * from './billing-stage-ref';
@ -79,6 +78,7 @@ export * from './health-response';
export * from './history-entry';
export * from './hook-definition';
export * from './hook-event';
export * from './idp-identity';
export * from './install-finish-response';
export * from './install-github-app-manifest-input';
export * from './install-github-app-manifest-response';
@ -164,6 +164,14 @@ export * from './preflight-response';
export * from './preflight-workflow-summary';
export * from './preview-url-request';
export * from './preview-url-response';
export * from './principal';
export * from './principal-agent';
export * from './principal-anonymous';
export * from './principal-slack';
export * from './principal-system';
export * from './principal-user';
export * from './principal-webhook';
export * from './principal-worker';
export * from './project-namespace';
export * from './provider';
export * from './prune-run-entry';
@ -272,6 +280,7 @@ export * from './stage-turn';
export * from './start-run-request';
export * from './submit-answer-request';
export * from './success-reason';
export * from './system-actor-kind';
export * from './system-features';
export * from './system-info-response';
export * from './system-run-counts';

View file

@ -0,0 +1,28 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface PrincipalAgent {
'kind': PrincipalAgentKindEnum;
'session_id'?: string | null;
'parent_session_id'?: string | null;
'model'?: string | null;
}
export const PrincipalAgentKindEnum = {
AGENT: 'agent'
} as const;
export type PrincipalAgentKindEnum = typeof PrincipalAgentKindEnum[keyof typeof PrincipalAgentKindEnum];

View file

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface PrincipalAnonymous {
'kind': PrincipalAnonymousKindEnum;
}
export const PrincipalAnonymousKindEnum = {
ANONYMOUS: 'anonymous'
} as const;
export type PrincipalAnonymousKindEnum = typeof PrincipalAnonymousKindEnum[keyof typeof PrincipalAnonymousKindEnum];

View file

@ -0,0 +1,28 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface PrincipalSlack {
'kind': PrincipalSlackKindEnum;
'team_id': string;
'user_id': string;
'user_name'?: string | null;
}
export const PrincipalSlackKindEnum = {
SLACK: 'slack'
} as const;
export type PrincipalSlackKindEnum = typeof PrincipalSlackKindEnum[keyof typeof PrincipalSlackKindEnum];

View file

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { SystemActorKind } from './system-actor-kind';
export interface PrincipalSystem {
'kind': PrincipalSystemKindEnum;
'system_kind': SystemActorKind;
}
export const PrincipalSystemKindEnum = {
SYSTEM: 'system'
} as const;
export type PrincipalSystemKindEnum = typeof PrincipalSystemKindEnum[keyof typeof PrincipalSystemKindEnum];

View file

@ -0,0 +1,34 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { AuthMethod } from './auth-method';
// May contain unused imports in some cases
// @ts-ignore
import type { IdpIdentity } from './idp-identity';
export interface PrincipalUser {
'kind': PrincipalUserKindEnum;
'identity': IdpIdentity;
'login': string;
'auth_method': AuthMethod;
}
export const PrincipalUserKindEnum = {
USER: 'user'
} as const;
export type PrincipalUserKindEnum = typeof PrincipalUserKindEnum[keyof typeof PrincipalUserKindEnum];

View file

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface PrincipalWebhook {
'kind': PrincipalWebhookKindEnum;
'delivery_id': string;
}
export const PrincipalWebhookKindEnum = {
WEBHOOK: 'webhook'
} as const;
export type PrincipalWebhookKindEnum = typeof PrincipalWebhookKindEnum[keyof typeof PrincipalWebhookKindEnum];

View file

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface PrincipalWorker {
'kind': PrincipalWorkerKindEnum;
'run_id': string;
}
export const PrincipalWorkerKindEnum = {
WORKER: 'worker'
} as const;
export type PrincipalWorkerKindEnum = typeof PrincipalWorkerKindEnum[keyof typeof PrincipalWorkerKindEnum];

View file

@ -0,0 +1,50 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { AuthMethod } from './auth-method';
// May contain unused imports in some cases
// @ts-ignore
import type { IdpIdentity } from './idp-identity';
// May contain unused imports in some cases
// @ts-ignore
import type { PrincipalAgent } from './principal-agent';
// May contain unused imports in some cases
// @ts-ignore
import type { PrincipalAnonymous } from './principal-anonymous';
// May contain unused imports in some cases
// @ts-ignore
import type { PrincipalSlack } from './principal-slack';
// May contain unused imports in some cases
// @ts-ignore
import type { PrincipalSystem } from './principal-system';
// May contain unused imports in some cases
// @ts-ignore
import type { PrincipalUser } from './principal-user';
// May contain unused imports in some cases
// @ts-ignore
import type { PrincipalWebhook } from './principal-webhook';
// May contain unused imports in some cases
// @ts-ignore
import type { PrincipalWorker } from './principal-worker';
// May contain unused imports in some cases
// @ts-ignore
import type { SystemActorKind } from './system-actor-kind';
/**
* @type Principal
*/
export type Principal = { kind: 'agent' } & PrincipalAgent | { kind: 'anonymous' } & PrincipalAnonymous | { kind: 'slack' } & PrincipalSlack | { kind: 'system' } & PrincipalSystem | { kind: 'user' } & PrincipalUser | { kind: 'webhook' } & PrincipalWebhook | { kind: 'worker' } & PrincipalWorker;

View file

@ -15,7 +15,7 @@
// May contain unused imports in some cases
// @ts-ignore
import type { ActorRef } from './actor-ref';
import type { Principal } from './principal';
/**
* Internal RunEvent-compatible JSON payload. The server validates this body by deserializing into the typed RunEvent struct.
@ -46,7 +46,7 @@ export interface RunEvent {
* Stable identifier for a tool call, present on agent.tool.* events and other durable events that directly describe the same tool call.
*/
'tool_call_id'?: string | null;
'actor'?: ActorRef | null;
'actor'?: Principal | null;
/**
* Event type discriminator.
*/

View file

@ -0,0 +1,24 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export const SystemActorKind = {
ENGINE: 'engine',
WATCHDOG: 'watchdog',
TIMEOUT: 'timeout'
} as const;
export type SystemActorKind = typeof SystemActorKind[keyof typeof SystemActorKind];