Skip to content

Commit b8a25d2

Browse files
committed
merge: combine OpenCode safety and resource envelope
2 parents b8a3b24 + 4922457 commit b8a25d2

7 files changed

Lines changed: 190 additions & 16 deletions

File tree

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ The compact declaration shape is:
187187
agent "<identity>" {
188188
host "<host>"
189189
workspace "<workspace>"
190-
resource "work" uri="github-issue://example/project/123"
190+
resource "work" uri="github-issue://example/project/123" reason="release work item"
191191
// Optional metadata:
192192
// role "worker"
193193
// supervisor "<supervisor-bus-id>"
@@ -250,12 +250,13 @@ It neither registers schemes, owns profile schemas, nor resolves targets.
250250
Binding order is irrelevant and names must be unique within the agent:
251251

252252
```kdl
253-
resource "work" uri="github-issue://example/project/123"
254-
resource "source" uri="worktree://github.com/example/project/change"
255-
resource "delivery" uri="ding://host/agent"
253+
resource "work" uri="github-issue://example/project/123" reason="release work item"
254+
resource "source" uri="worktree://github.com/example/project/change" reason="primary checkout"
255+
resource "delivery" uri="ding://host/agent" reason="notification channel for this agent"
256256
```
257257

258-
The envelope is intentionally only `name` + `uri`. It carries no required/optional,
258+
The envelope is `name` + `uri` + a required human-facing `reason`, plus an optional
259+
`inactive-reason` that preserves a retired binding without deleting it. It carries no
259260
access, readiness, or lifecycle policy, and URI possession conveys no authority. A Resource URI may
260261
be referenced by any number of agent declarations. Resource-only declaration edits do not stop,
261262
replace, or relaunch a live task. Resource profiles and resolvers remain opaque to st2; catalog

docs/vrs/spec.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,11 @@ the drift is fenced by
191191
An agent may directly declare zero or more generic Resource bindings:
192192

193193
```kdl
194-
resource "work" uri="github-issue://example/project/123"
194+
resource "work" uri="github-issue://example/project/123" reason="release work item"
195195
```
196196

197-
The positional name is an agent-local semantic role. `uri` is the exact RFC 3986 absolute resource
197+
The positional name is an agent-local semantic role. `reason` explains why the reference belongs
198+
to this agent (required; optional `inactive-reason` retains inactive bindings). `uri` is the exact RFC 3986 absolute resource
198199
identity, preserved byte-for-byte without normalization, and its scheme selects the open,
199200
downstream-owned Resource profile.
200201
Declaration order has no meaning and binding names are unique within one

src/catalog_transaction.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,18 @@ fn normalize_agent(spec: &agent_spec::AgentSpec) -> Result<BTreeMap<String, Sema
767767
SemanticType::String,
768768
resource.uri(),
769769
);
770+
insert_value(
771+
&mut fields,
772+
&format!("{root}/reason"),
773+
SemanticType::String,
774+
resource.reason(),
775+
);
776+
insert_optional(
777+
&mut fields,
778+
&format!("{root}/inactive-reason"),
779+
SemanticType::String,
780+
resource.inactive_reason(),
781+
);
770782
}
771783
for task in &spec.tasks {
772784
let kind = match task.kind {

src/ding/mod.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3292,6 +3292,87 @@ Enter to select · ↑/↓ to navigate · Esc to cancel";
32923292
);
32933293
}
32943294

3295+
/// Platforms that cannot hardlink through the open-file descriptor path (macOS fdescfs
3296+
/// rejects linkat(AT_SYMLINK_FOLLOW) on /dev/fd/N with EPERM) fall back to a byte-copy
3297+
/// archive receipt. The differentiated supersession semantics must hold unchanged on that
3298+
/// path, the receipt must carry exactly the validated bytes, and no staging file may leak
3299+
/// into the archive.
3300+
#[test]
3301+
#[cfg(debug_assertions)]
3302+
fn archive_copy_fallback_preserves_supersede_ownership_without_staging_leftovers() {
3303+
crate::event::TEST_FORCE_ARCHIVE_RECEIPT_COPY.store(true, Ordering::Relaxed);
3304+
struct ResetGuard;
3305+
impl Drop for ResetGuard {
3306+
fn drop(&mut self) {
3307+
crate::event::TEST_FORCE_ARCHIVE_RECEIPT_COPY.store(false, Ordering::Relaxed);
3308+
}
3309+
}
3310+
let _guard = ResetGuard;
3311+
3312+
let (catalog, inbox) = event_catalog();
3313+
let root = catalog.path();
3314+
let archive = crate::message::archive_dir(&root.join("hetz").join("worker"));
3315+
3316+
let failure_filename = emit_ci(root, "failure", true);
3317+
let mut seen = HashSet::new();
3318+
let mut pending: VecDeque<PendingNotice> = new_arrivals(&inbox, &mut seen)
3319+
.into_iter()
3320+
.map(PendingNotice::message)
3321+
.collect();
3322+
assert_eq!(pending.len(), 1);
3323+
let failure_bytes =
3324+
std::fs::read(inbox.join(&failure_filename)).expect("staged event bytes");
3325+
let failure_text = pending[0].text(
3326+
DingContext {
3327+
catalog_root: root,
3328+
this_host: "hetz",
3329+
recipient: "hetz.worker",
3330+
},
3331+
&mut None,
3332+
);
3333+
3334+
let poker = OwnershipPoker {
3335+
pokes: Mutex::new(Vec::new()),
3336+
retries: Mutex::new(Vec::new()),
3337+
poke_outcomes: Mutex::new(VecDeque::from([PokeOutcome::Staged])),
3338+
retry_outcomes: Mutex::new(VecDeque::from([PokeOutcome::Staged])),
3339+
};
3340+
flush_in(root, &mut pending, &poker);
3341+
3342+
emit_ci(root, "success", true);
3343+
pending.extend(
3344+
new_arrivals(&inbox, &mut seen)
3345+
.into_iter()
3346+
.map(PendingNotice::message),
3347+
);
3348+
prune_archived_pending(&inbox, &mut pending);
3349+
flush_in(root, &mut pending, &poker);
3350+
3351+
assert_eq!(pending.len(), 2, "later FIFO work remains blocked");
3352+
assert_eq!(
3353+
poker.pokes.lock().unwrap().as_slice(),
3354+
[failure_text.as_str()],
3355+
"the successor is never pasted on top of a retained payload"
3356+
);
3357+
assert!(!inbox.join(&failure_filename).exists(), "head was archived");
3358+
let receipt = std::fs::read(archive.join(&failure_filename))
3359+
.expect("byte-copy archive receipt exists");
3360+
assert_eq!(
3361+
receipt, failure_bytes,
3362+
"the copy receipt carries exactly the validated bytes"
3363+
);
3364+
let staging_leftovers: Vec<_> = std::fs::read_dir(&archive)
3365+
.unwrap()
3366+
.filter_map(Result::ok)
3367+
.map(|entry| entry.file_name().to_string_lossy().into_owned())
3368+
.filter(|name| name.starts_with(".st2-archive-"))
3369+
.collect();
3370+
assert!(
3371+
staging_leftovers.is_empty(),
3372+
"no staging files survive in the archive: {staging_leftovers:?}"
3373+
);
3374+
}
3375+
32953376
#[test]
32963377
fn archived_not_retained_releases_fifo_without_repasting_owned_notice() {
32973378
let agent = tempfile::tempdir().unwrap();

src/event.rs

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use std::os::unix::fs::DirBuilderExt as _;
1111
use std::os::unix::fs::MetadataExt as _;
1212
use std::os::unix::fs::OpenOptionsExt as _;
1313
use std::path::Path;
14-
use std::sync::atomic::{AtomicU64, Ordering};
14+
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
1515

1616
use anyhow::Context as _;
1717
use serde::{Deserialize, Serialize};
@@ -672,7 +672,14 @@ fn archive_validated_file(
672672
};
673673
if result < 0 {
674674
let error = std::io::Error::last_os_error();
675-
if error.kind() != std::io::ErrorKind::AlreadyExists {
675+
if error.kind() == std::io::ErrorKind::AlreadyExists {
676+
// A receipt for this predecessor already exists; the readback below proves it
677+
// carries exactly the validated bytes.
678+
} else if capability_link_unsupported(&error) {
679+
// The platform cannot hardlink through the open-file descriptor path at all;
680+
// degrade to a byte-copy receipt instead of failing publication.
681+
write_archive_receipt_copy(file, &archive_dir, archive, filename)?;
682+
} else {
676683
return Err(error).context("archive the validated predecessor capability");
677684
}
678685
}
@@ -690,6 +697,77 @@ fn archive_validated_file(
690697
Ok(())
691698
}
692699

700+
/// Whether linkat through the open-file capability path is unsupported by the platform rather
701+
/// than a real failure. macOS fdescfs answers linkat(AT_SYMLINK_FOLLOW) on /dev/fd/N with
702+
/// EPERM; ENOSYS/EOPNOTSUPP cover kernels lacking the syscall or its symlink-follow semantics.
703+
/// Everything else stays a hard error so genuine failures (permissions, cross-device, ...)
704+
/// surface instead of being silently degraded to a copy.
705+
fn capability_link_unsupported(error: &std::io::Error) -> bool {
706+
#[cfg(debug_assertions)]
707+
if TEST_FORCE_ARCHIVE_RECEIPT_COPY.load(Ordering::Relaxed) {
708+
return true;
709+
}
710+
matches!(
711+
error.raw_os_error(),
712+
Some(libc::EPERM) | Some(libc::ENOSYS) | Some(libc::EOPNOTSUPP)
713+
)
714+
}
715+
716+
/// Debug-only switch letting tests exercise the byte-copy fallback on platforms where the real
717+
/// capability linkat would succeed. Not a supported configuration knob. Flipping this mid-run
718+
/// is safe: the fallback receipt is verified against the validated bytes exactly like the
719+
/// hardlink path, and the same-inode unlink treats a copy receipt as "archived" regardless.
720+
#[cfg(debug_assertions)]
721+
pub(crate) static TEST_FORCE_ARCHIVE_RECEIPT_COPY: AtomicBool = AtomicBool::new(false);
722+
723+
/// Materialize the archive receipt as a byte copy of the validated file, for platforms that
724+
/// cannot hardlink through the open-file descriptor path.
725+
///
726+
/// The staged temp keeps a concurrent archiver from observing a partial receipt, and
727+
/// rename_noreplace turns an install race into a no-op: whichever receipt wins, the caller's
728+
/// readback proves it carries exactly the validated bytes. The tradeoff against the hardlink
729+
/// fast path is inode identity -- a crash between the copy and the conditional unlink leaves
730+
/// the retained inbox entry in place until revalidation, which the same-inode checks read as
731+
/// "still present", never as data loss.
732+
fn write_archive_receipt_copy(
733+
file: &File,
734+
archive_dir: &File,
735+
archive: &Path,
736+
filename: &str,
737+
) -> anyhow::Result<()> {
738+
let mut source = file.try_clone()?;
739+
source.rewind()?;
740+
let mut bytes = Vec::new();
741+
source.read_to_end(&mut bytes)?;
742+
drop(source);
743+
744+
let staged = archive.join(format!(
745+
".st2-archive-{}-{}",
746+
std::process::id(),
747+
TMP_COUNTER.fetch_add(1, Ordering::Relaxed)
748+
));
749+
let mut staged_file = OpenOptions::new()
750+
.read(true)
751+
.write(true)
752+
.create_new(true)
753+
.mode(0o600)
754+
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
755+
.open(&staged)?;
756+
staged_file.write_all(&bytes)?;
757+
staged_file.sync_all()?;
758+
drop(staged_file);
759+
760+
let target = archive.join(filename);
761+
if let Err(error) = crate::catalog_transaction::rename_noreplace(&staged, &target) {
762+
fs::remove_file(&staged).context("remove staging copy after failed receipt install")?;
763+
if error.kind() != std::io::ErrorKind::AlreadyExists {
764+
return Err(error).context("install archived predecessor receipt");
765+
}
766+
}
767+
archive_dir.sync_all()?;
768+
Ok(())
769+
}
770+
693771
fn conditional_unlink_same_inode(
694772
inbox_file: &File,
695773
expected_file: &File,

tests/status_agents.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ fn write(root: &Path, rel: &str, contents: &str) {
2323
fn agent_kdl(identity: &str, host: &str) -> String {
2424
format!(
2525
"agent \"{identity}\" {{\n identity \"{identity}\"\n host \"{host}\"\n \
26-
type \"service\"\n resource \"work\" uri=\"issue://example/{identity}\"\n \
26+
type \"service\"\n resource \"work\" uri=\"issue://example/{identity}\" reason=\"example work item\"\n \
2727
pty \"agent\" {{ command \"exec claude boot\" }}\n}}\n"
2828
)
2929
}
@@ -218,7 +218,8 @@ fn roster_json_and_human_output_distinguish_retirement_from_presence() {
218218
rows[0]["resources"],
219219
serde_json::json!([{
220220
"name": "work",
221-
"uri": "issue://example/live"
221+
"uri": "issue://example/live",
222+
"reason": "example work item"
222223
}])
223224
);
224225
assert_eq!(rows[1]["identity"], "h.retired");

tests/validate.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ fn opaque_resource_bindings_are_structurally_valid() {
7272
"Silber/cos/agent.kdl",
7373
r#"agent "cos" {
7474
host "Silber"
75-
resource "work" uri="vendor+thing://authority/exact%20identity"
75+
resource "work" uri="vendor+thing://authority/exact%20identity" reason="example vendor work item"
7676
command "codex"
7777
}"#,
7878
)]);
@@ -88,15 +88,15 @@ fn active_agents_may_share_an_opaque_resource_uri() {
8888
"h/reviewer/agent.kdl",
8989
r#"agent "reviewer" {
9090
host "h"
91-
resource "subject" uri="git-commit://github.com/example/project/0123456789abcdef"
91+
resource "subject" uri="git-commit://github.com/example/project/0123456789abcdef" reason="reviewed example commit"
9292
command "true"
9393
}"#,
9494
),
9595
(
9696
"h/integrator/agent.kdl",
9797
r#"agent "integrator" {
9898
host "h"
99-
resource "subject" uri="git-commit://github.com/example/project/0123456789abcdef"
99+
resource "subject" uri="git-commit://github.com/example/project/0123456789abcdef" reason="reviewed example commit"
100100
command "true"
101101
}"#,
102102
),
@@ -114,15 +114,15 @@ fn duplicate_bus_ids_remain_an_error_when_resources_are_shared() {
114114
"h/one/agent.kdl",
115115
r#"agent "worker" {
116116
host "h"
117-
resource "subject" uri="git-commit://github.com/example/project/0123456789abcdef"
117+
resource "subject" uri="git-commit://github.com/example/project/0123456789abcdef" reason="reviewed example commit"
118118
command "true"
119119
}"#,
120120
),
121121
(
122122
"h/two/agent.kdl",
123123
r#"agent "worker" {
124124
host "h"
125-
resource "subject" uri="git-commit://github.com/example/project/0123456789abcdef"
125+
resource "subject" uri="git-commit://github.com/example/project/0123456789abcdef" reason="reviewed example commit"
126126
command "true"
127127
}"#,
128128
),

0 commit comments

Comments
 (0)