From d45d15875c0f5c0901463a21460637b727dc73fb Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 17 Oct 2026 07:48:07 +0000 Subject: [PATCH 1/4] fix(cli): admin verbs fail the argparse way, with tests (WI-1751) A value the CLI cannot convert (token issue --expires-in-days x, a bad choice) now prints the usage synopsis and the 'vogt : error:' line on stderr, as argparse does, instead of the full help page on stdout. An unreadable --password-file or stdin secret is a domain error (exit 1, on stderr) where Python exits 1 too. Adds engine/core/tests/admin_cli.rs, which drives status, user create, token issue, backup and restore through the binary. openapi.rs is reformatted by cargo fmt (pre-existing drift on rust-core). Signed-off-by: sprooty Co-Authored-By: Claude Haiku 5.5 --- engine/core/src/adapters/cli.rs | 24 ++- engine/core/tests/admin_cli.rs | 350 ++++++++++++++++++++++++++++++++ 2 files changed, 366 insertions(+), 8 deletions(-) create mode 100644 engine/core/tests/admin_cli.rs diff --git a/engine/core/src/adapters/cli.rs b/engine/core/src/adapters/cli.rs index 4e75c6a0a..5d8545260 100644 --- a/engine/core/src/adapters/cli.rs +++ b/engine/core/src/adapters/cli.rs @@ -533,7 +533,8 @@ fn collect_params(argv: &[String], schema: &Value) -> Result { } index += 1; // A repeated secret flag is the last one, matching argparse. - values.insert(secret.to_string(), Value::String(read_secret_stdin()?)); + let text = read_secret_stdin().map_err(|message| format!("domain: {message}"))?; + values.insert(secret.to_string(), Value::String(text)); continue; } let value = take_value(argv, &mut index, inline, &name)?; @@ -578,8 +579,10 @@ fn collect_params(argv: &[String], schema: &Value) -> Result { continue; } let raw = take_value(argv, &mut index, inline, &name)?; - let parsed = - coerce(&raw, property).map_err(|message| format!("argument --{name}: {message}"))?; + // argparse rejects a value it cannot convert as an argument error: + // the usage synopsis, then `vogt : error:`. Not the full help page. + let parsed = coerce(&raw, property) + .map_err(|message| format!("arg: argument --{name}: {message}"))?; if is_list(property) { let entry = values .entry(field) @@ -590,7 +593,10 @@ fn collect_params(argv: &[String], schema: &Value) -> Result { } } for (secret, path) in &secret_files { - values.insert(secret.clone(), Value::String(read_secret_file(path)?)); + // An unreadable file is a domain error (exit 1, on stderr), as the + // secret-not-on-a-terminal case is. Python's uncaught OSError exits 1 too. + let text = read_secret_file(path).map_err(|message| format!("domain: {message}"))?; + values.insert(secret.clone(), Value::String(text)); } for name in &required { @@ -1959,8 +1965,10 @@ mod tests { &mut no_dispatch, ); assert_eq!(result.exit_code, EXIT_USAGE, "{}", result.stdout); + // argparse writes a usage error to stderr, and stdout stays empty. + assert!(result.stdout.is_empty(), "{}", result.stdout); assert!( - result.stdout.contains("invalid choice"), + result.stderr.contains("invalid choice"), "{}", result.stderr ); @@ -2504,9 +2512,9 @@ mod tests { ); assert_eq!(bad.exit_code, EXIT_USAGE, "{}", bad.stdout); assert!( - bad.stdout.contains("invalid int value: '-1.5'"), + bad.stderr.contains("invalid int value: '-1.5'"), "{}", - bad.stdout + bad.stderr ); } @@ -2599,6 +2607,6 @@ mod tests { &mut no_dispatch, ); assert_eq!(result.exit_code, EXIT_USAGE); - assert!(result.stdout.contains("invalid choice")); + assert!(result.stderr.contains("invalid choice")); } } diff --git a/engine/core/tests/admin_cli.rs b/engine/core/tests/admin_cli.rs new file mode 100644 index 000000000..bac815126 --- /dev/null +++ b/engine/core/tests/admin_cli.rs @@ -0,0 +1,350 @@ +//! The admin verbs an operator runs inside the container: `status`, +//! `user create`, `token issue`, `backup` and `restore`. Each one runs as the +//! binary runs it, against a fresh instance, and the exit codes and output +//! are the ones the Python CLI gives (`scripts/parity.py` pins the same steps +//! against the recorded golden). + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde_json::Value; + +const PASSWORD: &str = "correct horse battery"; + +fn binary() -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_vogt-core")); + // The same deterministic hooks the parity harness sets, so ids and clock + // stamps do not depend on the machine the test runs on. + command + .env("VOGT_TEST_CLOCK_START", "2026-01-02T03:04:05+00:00") + .env("VOGT_TEST_IDS", "sequential") + .env("USER", "parity") + .env("LOGNAME", "parity"); + command +} + +/// A fresh scratch root with an initialised instance under `instance/`. +fn fresh(name: &str) -> (PathBuf, PathBuf) { + let root = std::env::temp_dir().join(format!("vogt-admin-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let data = root.join("instance"); + let init = binary() + .arg("--json") + .arg("--data-dir") + .arg(&data) + .arg("init") + .output() + .expect("run init"); + assert!(init.status.success(), "init: {}", text(&init.stderr)); + (root, data) +} + +fn password_file(root: &Path) -> PathBuf { + let path = root.join("password"); + std::fs::write(&path, PASSWORD).unwrap(); + path +} + +/// Runs a verb in the global-flag position every caller uses. +fn run(data: &Path, args: &[&str]) -> Output { + binary() + .arg("--json") + .arg("--data-dir") + .arg(data) + .args(args) + .output() + .expect("run vogt-core") +} + +fn text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +fn json_of(output: &Output) -> Value { + serde_json::from_slice(&output.stdout) + .unwrap_or_else(|error| panic!("stdout is not JSON ({error}): {}", text(&output.stdout))) +} + +#[test] +fn status_reports_the_instance_after_init() { + let (_root, data) = fresh("status"); + let output = run(&data, &["status"]); + assert_eq!(output.status.code(), Some(0), "{}", text(&output.stderr)); + let body = json_of(&output); + assert_eq!(body["principal"], "local:parity"); + assert_eq!(body["counts"]["work_items"], 0); + assert_eq!(body["clone"], Value::Null); +} + +#[test] +fn user_create_then_token_issue_returns_a_secret_shown_once() { + let (root, data) = fresh("token"); + let password = password_file(&root); + let created = run( + &data, + &[ + "user", + "create", + "--username", + "ada", + "--display-name", + "Ada Lovelace", + "--password-file", + password.to_str().unwrap(), + "--reason", + "admin test", + ], + ); + assert_eq!(created.status.code(), Some(0), "{}", text(&created.stderr)); + let user = json_of(&created); + assert_eq!(user["user"]["actor_identity_ref"], "human:ada"); + assert_eq!( + user["user"]["scopes"], + serde_json::json!(["read", "work.write", "project.write"]) + ); + + let issued = run( + &data, + &[ + "token", + "issue", + "--actor", + "human:ada", + "--name", + "claude-code", + "--scopes", + "read,work.write", + "--reason", + "admin test", + ], + ); + assert_eq!(issued.status.code(), Some(0), "{}", text(&issued.stderr)); + let body = json_of(&issued); + assert!( + body["secret"] + .as_str() + .is_some_and(|secret| secret.len() > 20), + "{body}" + ); + assert_eq!(body["token"]["name"], "claude-code"); + assert!(body["warning"].as_str().is_some_and(|w| !w.is_empty())); +} + +#[test] +fn token_issue_refuses_an_unknown_scope_and_an_unknown_actor() { + let (root, data) = fresh("token-refusals"); + let password = password_file(&root); + let _ = run( + &data, + &[ + "user", + "create", + "--username", + "ada", + "--password-file", + password.to_str().unwrap(), + "--reason", + "admin test", + ], + ); + let bad_scope = run( + &data, + &[ + "token", + "issue", + "--actor", + "human:ada", + "--name", + "x", + "--scopes", + "read,bogus", + "--reason", + "admin test", + ], + ); + assert_eq!(bad_scope.status.code(), Some(1)); + assert!( + text(&bad_scope.stderr).contains("invalid_request: unknown scope 'bogus'"), + "{}", + text(&bad_scope.stderr) + ); + + let no_actor = run( + &data, + &[ + "token", + "issue", + "--actor", + "human:nobody", + "--name", + "x", + "--reason", + "admin test", + ], + ); + assert_eq!(no_actor.status.code(), Some(1)); + assert!( + text(&no_actor.stderr).contains("not_found"), + "{}", + text(&no_actor.stderr) + ); +} + +#[test] +fn a_non_integer_expiry_is_an_argparse_usage_error_not_the_help_page() { + let (_root, data) = fresh("expiry"); + let output = run( + &data, + &[ + "token", + "issue", + "--actor", + "human:ada", + "--name", + "x", + "--expires-in-days", + "x", + "--reason", + "admin test", + ], + ); + assert_eq!(output.status.code(), Some(2)); + let stderr = text(&output.stderr); + // The usage synopsis, then the one error line argparse prints. The full + // option table belongs to `--help`, not to a usage error. + assert!(stderr.starts_with("usage: vogt token issue ["), "{stderr}"); + assert!( + stderr.ends_with( + "vogt token issue: error: argument --expires-in-days: invalid int value: 'x'\n" + ), + "{stderr}" + ); + assert!(!stderr.contains("options:"), "{stderr}"); +} + +#[test] +fn user_create_refuses_an_unreadable_password_file_on_stderr() { + let (root, data) = fresh("user-missing-password"); + let output = run( + &data, + &[ + "user", + "create", + "--username", + "bob", + "--password-file", + root.join("missing").to_str().unwrap(), + "--reason", + "admin test", + ], + ); + // Python's uncaught OSError exits 1; the message goes to stderr, nothing to stdout. + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert!( + text(&output.stderr).contains("cannot read secret file"), + "{}", + text(&output.stderr) + ); +} + +#[test] +fn backup_then_restore_needs_confirm_and_then_restores() { + let (root, data) = fresh("backup"); + let destination = root.join("backup"); + let backup = run( + &data, + &[ + "backup", + "--destination", + destination.to_str().unwrap(), + "--label", + "admin-test", + "--reason", + "admin test", + ], + ); + assert_eq!(backup.status.code(), Some(0), "{}", text(&backup.stderr)); + assert!(destination.join("manifest.json").is_file()); + + // Backing up over a non-empty directory is refused, and writes nothing. + let again = run( + &data, + &[ + "backup", + "--destination", + destination.to_str().unwrap(), + "--label", + "admin-test", + "--reason", + "admin test", + ], + ); + assert_eq!(again.status.code(), Some(1)); + assert!( + text(&again.stderr).contains("conflict"), + "{}", + text(&again.stderr) + ); + + // Restore without --confirm is refused before anything is replaced. + let unconfirmed = run( + &data, + &[ + "restore", + "--source", + destination.to_str().unwrap(), + "--no-confirm", + "--reason", + "admin test", + ], + ); + assert_eq!(unconfirmed.status.code(), Some(1)); + assert!( + text(&unconfirmed.stderr).contains("Pass --confirm."), + "{}", + text(&unconfirmed.stderr) + ); + + let restored = run( + &data, + &[ + "restore", + "--source", + destination.to_str().unwrap(), + "--confirm", + "--reason", + "admin test", + ], + ); + assert_eq!( + restored.status.code(), + Some(0), + "{}", + text(&restored.stderr) + ); +} + +#[test] +fn restore_of_a_directory_that_is_not_a_backup_says_so() { + let (root, data) = fresh("restore-missing"); + let output = run( + &data, + &[ + "restore", + "--source", + root.join("nope").to_str().unwrap(), + "--confirm", + "--reason", + "admin test", + ], + ); + assert_eq!(output.status.code(), Some(1)); + assert!( + text(&output.stderr).contains("not_found") + && text(&output.stderr).contains("manifest.json"), + "{}", + text(&output.stderr) + ); +} From c3756f57563ed1d57a39f3c45d956988a20e417d Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 17 Oct 2026 07:48:07 +0000 Subject: [PATCH 2/4] test(parity): CLI token issue and backup/restore refusals; re-record 21c95f6 from Python (WI-1751) Adds the CLI token issue step the script lacked (it had only the HTTP one), a non-positive expiry refusal, a second backup over a non-empty destination, and restore without --confirm. Golden cli, http and mcp are re-recorded from the Python core at main 21c95f6; rust-core agrees on all three transports. Signed-off-by: sprooty Co-Authored-By: Claude Haiku 5.5 --- tests/parity/script.json | 51 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/parity/script.json b/tests/parity/script.json index d2533f173..fcd856c9f 100644 --- a/tests/parity/script.json +++ b/tests/parity/script.json @@ -1314,6 +1314,32 @@ "passwd" ] }, + { + "operation": "token.issue", + "params": { + "actor": "human:ada", + "name": "claude-code", + "scopes": "read,work.write", + "reason": "parity harness" + }, + "expect": { + "exit": 0 + } + }, + { + "operation": "token.issue", + "params": { + "actor": "human:ada", + "name": "bad expiry", + "expires_in_days": 0, + "reason": "parity harness" + }, + "expect": { + "exit": 2, + "capture_error": true + }, + "why": "a non-positive expiry is a validation refusal, exit 2" + }, { "operation": "token.list", "params": { @@ -1370,6 +1396,31 @@ }, "why": "LOCAL_ONLY in the registry: CLI and MCP only, not mounted on HTTP" }, + { + "operation": "backup", + "params": { + "destination": "{root}/backup", + "reason": "parity harness" + }, + "expect": { + "exit": 1, + "capture_error": true + }, + "why": "a second backup over a non-empty destination is refused and writes nothing" + }, + { + "operation": "restore", + "params": { + "source": "{root}/backup", + "confirm": false, + "reason": "parity harness" + }, + "expect": { + "exit": 1, + "capture_error": true + }, + "why": "restore without --confirm is refused before anything is replaced" + }, { "operation": "restore", "params": { From 3eee6ebbdbde8d4494888d8ef6ef3e6486e2ff06 Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 17 Oct 2026 08:06:25 +0000 Subject: [PATCH 3/4] fix(cli): report a missing required argument before reading a secret (WI-1751) collect_params read --password-file and --password-stdin before it checked the required non-secret flags, so a missing file or a waiting stdin came ahead of the usage error. The required check now runs first, and stdin is read only after it. Adds a test for user create with no --username and a missing password file: exit 2 with argparse's usage line. Signed-off-by: sprooty Co-Authored-By: Claude Haiku 5.5 --- engine/core/src/adapters/cli.rs | 59 +++++++++++++++++---------------- engine/core/tests/admin_cli.rs | 29 ++++++++++++++++ 2 files changed, 60 insertions(+), 28 deletions(-) diff --git a/engine/core/src/adapters/cli.rs b/engine/core/src/adapters/cli.rs index 5d8545260..82486eb26 100644 --- a/engine/core/src/adapters/cli.rs +++ b/engine/core/src/adapters/cli.rs @@ -459,6 +459,9 @@ fn collect_params(argv: &[String], schema: &Value) -> Result { let mut values: Map = Map::new(); let mut secret_files: BTreeMap = BTreeMap::new(); let mut secret_sources: BTreeMap = BTreeMap::new(); + // stdin is read only after the required arguments are checked, so a usage + // error never consumes or waits on standard input. + let mut stdin_secrets: Vec = Vec::new(); let mut index = 0usize; while index < argv.len() { let token = &argv[index]; @@ -532,9 +535,7 @@ fn collect_params(argv: &[String], schema: &Value) -> Result { return Err(format!("--{name} takes no value")); } index += 1; - // A repeated secret flag is the last one, matching argparse. - let text = read_secret_stdin().map_err(|message| format!("domain: {message}"))?; - values.insert(secret.to_string(), Value::String(text)); + stdin_secrets.push(secret.to_string()); continue; } let value = take_value(argv, &mut index, inline, &name)?; @@ -592,6 +593,23 @@ fn collect_params(argv: &[String], schema: &Value) -> Result { values.insert(field, parsed); } } + // Required arguments that are not secrets are a usage error, and that check + // runs before any secret is read, as argparse does before pydantic does. + let missing: Vec = required + .iter() + .filter(|name| { + !is_secret(name) && !is_nullable(properties.get(*name)) && !values.contains_key(*name) + }) + .map(|name| format!("--{}", name.replace('_', "-"))) + .collect(); + if !missing.is_empty() { + return Err(format!("usage: {}", missing.join(", "))); + } + for secret in &stdin_secrets { + // A repeated secret flag is the last one, matching argparse. + let text = read_secret_stdin().map_err(|message| format!("domain: {message}"))?; + values.insert(secret.clone(), Value::String(text)); + } for (secret, path) in &secret_files { // An unreadable file is a domain error (exit 1, on stderr), as the // secret-not-on-a-terminal case is. Python's uncaught OSError exits 1 too. @@ -599,32 +617,17 @@ fn collect_params(argv: &[String], schema: &Value) -> Result { values.insert(secret.clone(), Value::String(text)); } - for name in &required { - if is_secret(name) { - if !values.contains_key(name) { - let dashed = name.replace('_', "-"); - // Off a terminal there is no prompt, so a missing secret is a - // domain error (exit 1), not a usage error. - if !io::stdin().is_terminal() { - return Err(format!( - "domain: --{dashed}-file or --{dashed}-stdin is required when not on a terminal" - )); - } - return Err(format!("--{dashed}-file or --{dashed}-stdin is required")); + for name in required.iter().filter(|name| is_secret(name)) { + if !values.contains_key(name) { + let dashed = name.replace('_', "-"); + // Off a terminal there is no prompt, so a missing secret is a + // domain error (exit 1), not a usage error. + if !io::stdin().is_terminal() { + return Err(format!( + "domain: --{dashed}-file or --{dashed}-stdin is required when not on a terminal" + )); } - continue; - } - let missing: Vec = required - .iter() - .filter(|name| { - !is_secret(name) - && !is_nullable(properties.get(*name)) - && !values.contains_key(*name) - }) - .map(|name| format!("--{}", name.replace('_', "-"))) - .collect(); - if !missing.is_empty() { - return Err(format!("usage: {}", missing.join(", "))); + return Err(format!("--{dashed}-file or --{dashed}-stdin is required")); } } Ok(Value::Object(values)) diff --git a/engine/core/tests/admin_cli.rs b/engine/core/tests/admin_cli.rs index bac815126..2ad0af21a 100644 --- a/engine/core/tests/admin_cli.rs +++ b/engine/core/tests/admin_cli.rs @@ -348,3 +348,32 @@ fn restore_of_a_directory_that_is_not_a_backup_says_so() { text(&output.stderr) ); } + +#[test] +fn a_missing_required_argument_is_usage_before_a_secret_file_is_read() { + let (root, data) = fresh("required-before-secret"); + // `--username` is absent. The password file is also missing, so reading it + // first would report that file instead of the usage error. + let output = run( + &data, + &[ + "user", + "create", + "--password-file", + root.join("missing").to_str().unwrap(), + "--reason", + "admin test", + ], + ); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty(), "{}", text(&output.stdout)); + let stderr = text(&output.stderr); + assert!(stderr.starts_with("usage: vogt user create ["), "{stderr}"); + assert!( + stderr.ends_with( + "vogt user create: error: the following arguments are required: --username\n" + ), + "{stderr}" + ); + assert!(!stderr.contains("cannot read secret file"), "{stderr}"); +} From 8b17f35de9c30370627aec8f25b162c51562f929 Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 17 Oct 2026 08:39:04 +0000 Subject: [PATCH 4/4] test(parity): re-record the 21c95f6 golden on rust-core after #1078 (WI-1751) Co-Authored-By: Claude Opus 5.5 Signed-off-by: sprooty --- tests/parity/golden/21c95f6/cli.json | 116 ++++++++++++---- tests/parity/golden/21c95f6/mcp.json | 183 +++++++++++++++++--------- tests/parity/golden/21c95f6/meta.json | 2 +- 3 files changed, 206 insertions(+), 95 deletions(-) diff --git a/tests/parity/golden/21c95f6/cli.json b/tests/parity/golden/21c95f6/cli.json index 2d212d755..df664d980 100644 --- a/tests/parity/golden/21c95f6/cli.json +++ b/tests/parity/golden/21c95f6/cli.json @@ -3532,6 +3532,36 @@ "exit": 0, "operation": "user.set_password" }, + { + "body": { + "secret": "", + "token": { + "actor_id": "act_0005", + "actor_identity_ref": "human:ada", + "created_at": "2026-01-02T03:04:05Z", + "expires_at": null, + "id": "tok_0003", + "kind": "api", + "last_used_at": null, + "name": "claude-code", + "revoked_at": null, + "revoked_reason": null, + "scopes": [ + "read", + "work.write" + ] + }, + "warning": "This is the only time the secret is shown. Store it in a file and point VOGT_TOKEN_FILE at it \u2014 never in argv or a URL." + }, + "exit": 0, + "operation": "token.issue" + }, + { + "body": null, + "error": "error: invalid arguments for token.issue:", + "exit": 2, + "operation": "token.issue" + }, { "body": { "tokens": [ @@ -3569,6 +3599,22 @@ "work.write", "project.write" ] + }, + { + "actor_id": "act_0005", + "actor_identity_ref": "human:ada", + "created_at": "2026-01-02T03:04:05Z", + "expires_at": null, + "id": "tok_0003", + "kind": "api", + "last_used_at": null, + "name": "claude-code", + "revoked_at": null, + "revoked_reason": null, + "scopes": [ + "read", + "work.write" + ] } ] }, @@ -3629,6 +3675,18 @@ "exit": 0, "operation": "backup" }, + { + "body": null, + "error": "error: conflict: /backup already exists and is not empty", + "exit": 1, + "operation": "backup" + }, + { + "body": null, + "error": "error: invalid_request: this replaces the stores in /instance with the backup taken at 2026-01-02T03:04:05+00:00. Pass --confirm.", + "exit": 1, + "operation": "restore" + }, { "body": { "declared_schema_version": 24, @@ -3782,12 +3840,12 @@ "at": "2026-01-02T03:04:06Z", "entity_id": "act_0005", "entity_kind": "user", - "id": "aud_0028", + "id": "aud_0029", "operation": "user.remove", "payload_digest": "sha256:6cb1b570d1a756565af0191ec35d1fed1dc103ed9b82afa000b68a0172b6f711", "reason": "parity harness", - "revision": 27, - "txn_id": "txn_0036" + "revision": 28, + "txn_id": "txn_0037" }, { "actor_id": "act_0001", @@ -3795,10 +3853,23 @@ "at": "2026-01-02T03:04:06Z", "entity_id": "tok_0002", "entity_kind": "token", - "id": "aud_0027", + "id": "aud_0028", "operation": "token.revoke", "payload_digest": "sha256:5b5962b852bf72720c110fabfda9bea2589305bb33f47f25d1361ce815fa78c9", "reason": "parity harness", + "revision": 27, + "txn_id": "txn_0035" + }, + { + "actor_id": "act_0001", + "actor_identity_ref": "local:parity", + "at": "2026-01-02T03:04:06Z", + "entity_id": "tok_0003", + "entity_kind": "token", + "id": "aud_0027", + "operation": "token.issue", + "payload_digest": "sha256:095bcb2a75c36880ea4d27397304f88e0afd90440f45bee38fabb4431e383f55", + "reason": "parity harness", "revision": 26, "txn_id": "txn_0034" }, @@ -3827,22 +3898,9 @@ "reason": "parity harness", "revision": 24, "txn_id": "txn_0032" - }, - { - "actor_id": "act_0001", - "actor_identity_ref": "local:parity", - "at": "2026-01-02T03:04:06Z", - "entity_id": "prj_0003", - "entity_kind": "project", - "id": "aud_0024", - "operation": "project.register", - "payload_digest": "", - "reason": "parity harness", - "revision": 23, - "txn_id": "txn_0026" } ], - "total": 28 + "total": 29 }, "exit": 0, "operation": "audit.list" @@ -3856,28 +3914,28 @@ "at": "2026-01-02T03:04:06Z", "entity_id": "tok_0002", "entity_kind": "token", - "id": "aud_0027", + "id": "aud_0028", "operation": "token.revoke", "payload_digest": "sha256:5b5962b852bf72720c110fabfda9bea2589305bb33f47f25d1361ce815fa78c9", "reason": "parity harness", - "revision": 26, - "txn_id": "txn_0034" + "revision": 27, + "txn_id": "txn_0035" }, { "actor_id": "act_0001", "actor_identity_ref": "local:parity", "at": "2026-01-02T03:04:06Z", - "entity_id": "act_0005", - "entity_kind": "user", - "id": "aud_0026", - "operation": "user.set_password", - "payload_digest": "sha256:6782e2b5fa4d26772e8b2eeacbf82bb2c48960ddb25c736b28001d387114acf5", + "entity_id": "tok_0003", + "entity_kind": "token", + "id": "aud_0027", + "operation": "token.issue", + "payload_digest": "sha256:095bcb2a75c36880ea4d27397304f88e0afd90440f45bee38fabb4431e383f55", "reason": "parity harness", - "revision": 25, - "txn_id": "txn_0033" + "revision": 26, + "txn_id": "txn_0034" } ], - "total": 28 + "total": 29 }, "exit": 0, "operation": "audit.list" diff --git a/tests/parity/golden/21c95f6/mcp.json b/tests/parity/golden/21c95f6/mcp.json index 3891f8e90..9168e91ff 100644 --- a/tests/parity/golden/21c95f6/mcp.json +++ b/tests/parity/golden/21c95f6/mcp.json @@ -1195,7 +1195,7 @@ "entity_kind": "project", "id": "aud_0007", "operation": "project.update", - "payload_digest": "sha256:85cef2a14b2915d804f8a1f0719eda74903a43d1a02acb5cd724a7116e91b517", + "payload_digest": "sha256:1f9f892df27e35a0a5098a2419d32395f31d2b6a81f7ca3a6502eaf32744fc00", "reason": "parity harness", "revision": 6, "txn_id": "txn_0007" @@ -1207,7 +1207,7 @@ "entity_kind": "project", "id": "aud_0008", "operation": "project.transition", - "payload_digest": "sha256:d0cff29b2f2ccbadaec9a3c59ffe9f147af198d477b8a01ca760d9dd27247a2a", + "payload_digest": "sha256:ef72486698b013b76de8d9a55095b56cef8897ac29fc98f7759ed848ab6f5c79", "reason": "parity harness", "revision": 7, "txn_id": "txn_0008" @@ -1219,7 +1219,7 @@ "entity_kind": "project", "id": "aud_0009", "operation": "project.scaffold", - "payload_digest": "sha256:63107a85166b692b1819b975af61afe2e585b9921f36531a77717420e2922fd6", + "payload_digest": "sha256:bd2600c2370f8743a2c4ae1d595aec4a459745b14e3d16323948dd47878c48e8", "reason": "parity harness", "revision": 8, "txn_id": "txn_0009" @@ -3238,9 +3238,48 @@ "is_error": false, "operation": "user.set_password" }, + { + "body": { + "secret": "", + "token": { + "actor_id": "act_0005", + "actor_identity_ref": "human:ada", + "created_at": "2026-01-02T03:05:30Z", + "id": "tok_0003", + "kind": "api", + "name": "claude-code", + "scopes": [ + "read", + "work.write" + ] + }, + "warning": "This is the only time the secret is shown. Store it in a file and point VOGT_TOKEN_FILE at it \u2014 never in argv or a URL." + }, + "is_error": false, + "operation": "token.issue" + }, + { + "body": { + "text": "invalid_params: expires_in_days: Input should be greater than or equal to 1. token_issue takes required: actor, name, reason; optional: scopes, expires_in_days" + }, + "is_error": true, + "operation": "token.issue" + }, { "body": { "tokens": [ + { + "actor_id": "act_0005", + "actor_identity_ref": "human:ada", + "created_at": "2026-01-02T03:05:30Z", + "id": "tok_0003", + "kind": "api", + "name": "claude-code", + "scopes": [ + "read", + "work.write" + ] + }, { "actor_id": "act_0003", "actor_identity_ref": "agent:vogt-sessions", @@ -3282,7 +3321,7 @@ "id": "tok_0002", "kind": "api", "name": "bootstrap agent token", - "revoked_at": "2026-01-02T03:05:30Z", + "revoked_at": "2026-01-02T03:05:32Z", "revoked_reason": "parity harness", "scopes": [ "read", @@ -3319,11 +3358,25 @@ "instance_id": "ins_0001", "observed_schema_version": 6, "path": "/backup", - "taken_at": "2026-01-02T03:05:32Z" + "taken_at": "2026-01-02T03:05:34Z" }, "is_error": false, "operation": "backup" }, + { + "body": { + "text": "conflict: /backup already exists and is not empty" + }, + "is_error": true, + "operation": "backup" + }, + { + "body": { + "text": "invalid_request: this replaces the stores in /instance with the backup taken at 2026-01-02T03:05:34+00:00. Pass --confirm." + }, + "is_error": true, + "operation": "restore" + }, { "body": { "declared_schema_version": 24, @@ -3332,7 +3385,7 @@ "import_root_then": "/instance/repos", "instance_id": "ins_0001", "migrations_applied": [], - "restored_from": "2026-01-02T03:05:32Z", + "restored_from": "2026-01-02T03:05:34Z", "source": "/backup" }, "is_error": false, @@ -3474,25 +3527,38 @@ { "actor_id": "act_0001", "actor_identity_ref": "local:parity", - "at": "2026-01-02T03:05:37Z", + "at": "2026-01-02T03:05:40Z", "entity_id": "act_0005", "entity_kind": "user", - "id": "aud_0025", + "id": "aud_0026", "operation": "user.remove", "payload_digest": "sha256:6cb1b570d1a756565af0191ec35d1fed1dc103ed9b82afa000b68a0172b6f711", "reason": "parity harness", + "revision": 25, + "txn_id": "txn_0036" + }, + { + "actor_id": "act_0001", + "actor_identity_ref": "local:parity", + "at": "2026-01-02T03:05:33Z", + "entity_id": "tok_0002", + "entity_kind": "token", + "id": "aud_0025", + "operation": "token.revoke", + "payload_digest": "sha256:ea023b94520fee9aa219311231038a5eafff92a036bb0970996d28607a1ddf1e", + "reason": "parity harness", "revision": 24, - "txn_id": "txn_0035" + "txn_id": "txn_0034" }, { "actor_id": "act_0001", "actor_identity_ref": "local:parity", "at": "2026-01-02T03:05:31Z", - "entity_id": "tok_0002", + "entity_id": "tok_0003", "entity_kind": "token", "id": "aud_0024", - "operation": "token.revoke", - "payload_digest": "sha256:8df0a912a481d9f60a40b1711ebc52960d101752220f6647c5fa559b92df773a", + "operation": "token.issue", + "payload_digest": "sha256:095bcb2a75c36880ea4d27397304f88e0afd90440f45bee38fabb4431e383f55", "reason": "parity harness", "revision": 23, "txn_id": "txn_0033" @@ -3522,22 +3588,9 @@ "reason": "parity harness", "revision": 21, "txn_id": "txn_0031" - }, - { - "actor_id": "act_0001", - "actor_identity_ref": "local:parity", - "at": "2026-01-02T03:05:02Z", - "entity_id": "prj_0003", - "entity_kind": "project", - "id": "aud_0021", - "operation": "project.register", - "payload_digest": "", - "reason": "parity harness", - "revision": 20, - "txn_id": "txn_0025" } ], - "total": 25 + "total": 26 }, "is_error": false, "operation": "audit.list" @@ -3548,31 +3601,31 @@ { "actor_id": "act_0001", "actor_identity_ref": "local:parity", - "at": "2026-01-02T03:05:31Z", + "at": "2026-01-02T03:05:33Z", "entity_id": "tok_0002", "entity_kind": "token", - "id": "aud_0024", + "id": "aud_0025", "operation": "token.revoke", - "payload_digest": "sha256:8df0a912a481d9f60a40b1711ebc52960d101752220f6647c5fa559b92df773a", + "payload_digest": "sha256:ea023b94520fee9aa219311231038a5eafff92a036bb0970996d28607a1ddf1e", "reason": "parity harness", - "revision": 23, - "txn_id": "txn_0033" + "revision": 24, + "txn_id": "txn_0034" }, { "actor_id": "act_0001", "actor_identity_ref": "local:parity", - "at": "2026-01-02T03:05:29Z", - "entity_id": "act_0005", - "entity_kind": "user", - "id": "aud_0023", - "operation": "user.set_password", - "payload_digest": "sha256:6782e2b5fa4d26772e8b2eeacbf82bb2c48960ddb25c736b28001d387114acf5", + "at": "2026-01-02T03:05:31Z", + "entity_id": "tok_0003", + "entity_kind": "token", + "id": "aud_0024", + "operation": "token.issue", + "payload_digest": "sha256:095bcb2a75c36880ea4d27397304f88e0afd90440f45bee38fabb4431e383f55", "reason": "parity harness", - "revision": 22, - "txn_id": "txn_0032" + "revision": 23, + "txn_id": "txn_0033" } ], - "total": 25 + "total": 26 }, "is_error": false, "operation": "audit.list" @@ -3617,8 +3670,8 @@ "decision": { "asker": "local:parity", "context": "CI is green.", - "created_at": "2026-01-02T03:05:38Z", - "expires_at": "2026-01-03T03:05:38Z", + "created_at": "2026-01-02T03:05:41Z", + "expires_at": "2026-01-03T03:05:41Z", "id": "dec_0001", "options": [ { @@ -3645,8 +3698,8 @@ "decision": { "asker": "local:parity", "context": "", - "created_at": "2026-01-02T03:05:41Z", - "expires_at": "2026-01-03T03:05:41Z", + "created_at": "2026-01-02T03:05:44Z", + "expires_at": "2026-01-03T03:05:44Z", "id": "dec_0002", "options": [ { @@ -3674,8 +3727,8 @@ { "asker": "local:parity", "context": "", - "created_at": "2026-01-02T03:05:41Z", - "expires_at": "2026-01-03T03:05:41Z", + "created_at": "2026-01-02T03:05:44Z", + "expires_at": "2026-01-03T03:05:44Z", "id": "dec_0002", "options": [ { @@ -3696,8 +3749,8 @@ { "asker": "local:parity", "context": "CI is green.", - "created_at": "2026-01-02T03:05:38Z", - "expires_at": "2026-01-03T03:05:38Z", + "created_at": "2026-01-02T03:05:41Z", + "expires_at": "2026-01-03T03:05:41Z", "id": "dec_0001", "options": [ { @@ -3726,8 +3779,8 @@ { "asker": "local:parity", "context": "", - "created_at": "2026-01-02T03:05:41Z", - "expires_at": "2026-01-03T03:05:41Z", + "created_at": "2026-01-02T03:05:44Z", + "expires_at": "2026-01-03T03:05:44Z", "id": "dec_0002", "options": [ { @@ -3748,8 +3801,8 @@ { "asker": "local:parity", "context": "CI is green.", - "created_at": "2026-01-02T03:05:38Z", - "expires_at": "2026-01-03T03:05:38Z", + "created_at": "2026-01-02T03:05:41Z", + "expires_at": "2026-01-03T03:05:41Z", "id": "dec_0001", "options": [ { @@ -3830,7 +3883,7 @@ "evidence_snapshot": "", "freshness": "live", "kind": "decision.pending", - "occurred_at": "2026-01-02T03:05:41Z", + "occurred_at": "2026-01-02T03:05:44Z", "provisional": true, "source": "agent", "source_subject_key": "dec_0002", @@ -3851,7 +3904,7 @@ "evidence_snapshot": "", "freshness": "live", "kind": "decision.pending", - "occurred_at": "2026-01-02T03:05:38Z", + "occurred_at": "2026-01-02T03:05:41Z", "provisional": true, "source": "agent", "source_subject_key": "dec_0001", @@ -3863,10 +3916,10 @@ ], "github_scope": "registered projects only", "high_water": { - "agent": "2026-01-02T03:05:41Z" + "agent": "2026-01-02T03:05:44Z" }, "instance_scope": "registered projects only", - "snapshot_at": "2026-01-02T03:05:48Z" + "snapshot_at": "2026-01-02T03:05:51Z" }, "is_error": false, "operation": "inbox.list" @@ -3878,12 +3931,12 @@ "label": "Merge", "note": "reviewed" }, - "answered_at": "2026-01-02T03:05:54Z", + "answered_at": "2026-01-02T03:05:57Z", "answered_by": "local:parity", "asker": "local:parity", "context": "CI is green.", - "created_at": "2026-01-02T03:05:38Z", - "expires_at": "2026-01-03T03:05:38Z", + "created_at": "2026-01-02T03:05:41Z", + "expires_at": "2026-01-03T03:05:41Z", "id": "dec_0001", "options": [ { @@ -3924,8 +3977,8 @@ "decision": { "asker": "local:parity", "context": "", - "created_at": "2026-01-02T03:05:41Z", - "expires_at": "2026-01-03T03:05:41Z", + "created_at": "2026-01-02T03:05:44Z", + "expires_at": "2026-01-03T03:05:44Z", "id": "dec_0002", "options": [ { @@ -3955,12 +4008,12 @@ "label": "Merge", "note": "reviewed" }, - "answered_at": "2026-01-02T03:05:54Z", + "answered_at": "2026-01-02T03:05:57Z", "answered_by": "local:parity", "asker": "local:parity", "context": "CI is green.", - "created_at": "2026-01-02T03:05:38Z", - "expires_at": "2026-01-03T03:05:38Z", + "created_at": "2026-01-02T03:05:41Z", + "expires_at": "2026-01-03T03:05:41Z", "id": "dec_0001", "options": [ { @@ -4032,7 +4085,7 @@ "github_scope": "registered projects only", "high_water": {}, "instance_scope": "registered projects only", - "snapshot_at": "2026-01-02T03:06:02Z" + "snapshot_at": "2026-01-02T03:06:05Z" }, "is_error": false, "operation": "inbox.list" diff --git a/tests/parity/golden/21c95f6/meta.json b/tests/parity/golden/21c95f6/meta.json index 35878d2a8..37a355306 100644 --- a/tests/parity/golden/21c95f6/meta.json +++ b/tests/parity/golden/21c95f6/meta.json @@ -1,5 +1,5 @@ { "main_sha": "21c95f674e1b03ee177394f605abb1abfac41cdd", - "recorded_at": "2026-10-17T08:16:32Z", + "recorded_at": "2026-10-17T08:38:48Z", "recorder": "scripts/parity.py" }