ace-hitl-hermes 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/README.md +78 -0
- data/Rakefile +10 -0
- data/lib/ace/hitl/hermes/atoms/hermes_tokens.rb +34 -0
- data/lib/ace/hitl/hermes/errors.rb +35 -0
- data/lib/ace/hitl/hermes/molecules/hermes_atomic_writer.rb +56 -0
- data/lib/ace/hitl/hermes/molecules/hermes_channels.rb +90 -0
- data/lib/ace/hitl/hermes/molecules/hermes_contract.rb +85 -0
- data/lib/ace/hitl/hermes/molecules/hermes_formats.rb +61 -0
- data/lib/ace/hitl/hermes/molecules/hermes_message.rb +163 -0
- data/lib/ace/hitl/hermes/molecules/hermes_notifications.rb +53 -0
- data/lib/ace/hitl/hermes/molecules/hermes_quarantine.rb +60 -0
- data/lib/ace/hitl/hermes/molecules/hermes_retry_policy.rb +43 -0
- data/lib/ace/hitl/hermes/organisms/hermes_box.rb +240 -0
- data/lib/ace/hitl/hermes/schemas/message.v1.schema.json +84 -0
- data/lib/ace/hitl/hermes/version.rb +10 -0
- data/lib/ace/hitl/hermes.rb +27 -0
- metadata +108 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4748399831bb81daa3c725bed83373df44675fa005410f22e88632073abe839a
|
|
4
|
+
data.tar.gz: 63d3164afdfb14a1ff5072f553bc9a5c5f9e3a092ebb4088ab0822bf8522fddc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e6781892f412b2c1ba7798f2725a3c454f7c72844de9c81a68224add320e636539863fba1201bdb15d2753f878f5b932738b0fab1e34eaab8fc3e76a9a342cb7
|
|
7
|
+
data.tar.gz: '07811266c2c80bf1277575bf6e295b522133ea4f06169cdface8d761b4c2ce9216ae65f22b127b8e3b552df58adc57a7e30be995cc5c82091237838a353e21d9'
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `ace-hitl-hermes` will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-09-27
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Folder-as-interface plugin + registry (spec 8wm.t.vs1)**: new package owning the channel registry, notification texts, and message formats for the shared lab <-> hermes folder. Folder contract `ace.hitl.hermes.folder/v1` + message schema `ace.hitl.hermes.message/v1` (JSON Schema asset shipped at `lib/ace/hitl/hermes/schemas/message.v1.schema.json`): a message is a file named `<id>.json`, the address is `<machine>/<folder>/<id>`, the Captain's answer file carries `answer` / `sender` / `received_at`. Fail-closed filename validation, UTF-8 + 64 KiB bounds, id/filename cross-check, ownership/permissions (files 0640, tmp 0600, quarantine 0750; running as root is refused), atomic same-directory tmp+rename writes, quarantine for invalid files with reason sidecars, bounded collision and undeleted-file retry policies, and the write -> validate -> deliver -> ACK deletion state machine. Canonical shared-contract file location/ownership is settled by A4 (8wm.t.vs2); the lab-config consumer side is 8wm.t.vp9 (separate repo).
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **PR#336 review hardening (codex astra high)**: `publish` validates the exact serialized envelope bytes through the consumer decode gate (UTF-8 + 64 KiB) before any disk write, so an oversized envelope fails at the producer instead of being terminally quarantined by its own poll; `poll` opens message files with `O_NOFOLLOW` and requires a regular file on the descriptor, quarantining top-level symlinks instead of following them (no re-delivery of quarantined content, no importing foreign content through links); the shipped JSON Schema now rejects the envelopes the Ruby exact-field and body-strip checks reject (mutually exclusive kind fields, nonblank bodies).
|
data/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# ace-hitl-hermes
|
|
2
|
+
|
|
3
|
+
Folder-as-interface HITL transport plugin for the hermes relay
|
|
4
|
+
(spec `8wm.t.vs1`). The shared folder between the lab and hermes **is**
|
|
5
|
+
the transport:
|
|
6
|
+
|
|
7
|
+
- a **message** is a file `<id>.json` in the channel folder;
|
|
8
|
+
- the **address** is `<machine>/<folder>/<id>`;
|
|
9
|
+
- **delivery** is push — the consuming side picks the validated file up
|
|
10
|
+
and takes it to its target (labd pushes answers to the asking agent,
|
|
11
|
+
hermes surfaces questions to the Captain);
|
|
12
|
+
- **ACK** is the deletion of the file after delivery.
|
|
13
|
+
|
|
14
|
+
The Captain's answer is a file `<folder>/<id>.json` with the fields
|
|
15
|
+
`answer` / `sender` / `received_at`, written atomically (same-directory
|
|
16
|
+
tmp file + rename) without root.
|
|
17
|
+
|
|
18
|
+
## Contract
|
|
19
|
+
|
|
20
|
+
- Folder contract: `ace.hitl.hermes.folder/v1` (spec `8wm.t.vs1`).
|
|
21
|
+
- Message schema: `ace.hitl.hermes.message/v1`; the machine-readable
|
|
22
|
+
JSON Schema ships at
|
|
23
|
+
`lib/ace/hitl/hermes/schemas/message.v1.schema.json`.
|
|
24
|
+
- State machine: write -> validate -> deliver -> ACK deletion, with
|
|
25
|
+
bounded retries (collision at write time, undeleted after delivery)
|
|
26
|
+
and quarantine as the terminal branch for invalid files.
|
|
27
|
+
- Canonical shared-contract file location/ownership is settled by A4
|
|
28
|
+
(`8wm.t.vs2`); the lab-config consumer side is `8wm.t.vp9` (separate
|
|
29
|
+
repository, intentional cross-repo reference).
|
|
30
|
+
|
|
31
|
+
## Surface
|
|
32
|
+
|
|
33
|
+
| Piece | Responsibility |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `Hermes::Molecules::HermesChannels::Registry` | channel registry + `<machine>/<folder>/<id>` addressing, fail closed |
|
|
36
|
+
| `Hermes::Molecules::HermesFormats` | decode gate: UTF-8, 64 KiB bound, JSON object, known schema only |
|
|
37
|
+
| `Hermes::Molecules::HermesMessage` | typed envelope, fail-closed validation, canonical JSON |
|
|
38
|
+
| `Hermes::Molecules::HermesAtomicWriter` | same-directory tmp + rename, no root (euid 0 refused) |
|
|
39
|
+
| `Hermes::Molecules::HermesQuarantine` | quarantine moves + reason sidecars |
|
|
40
|
+
| `Hermes::Molecules::HermesRetryPolicy` | collision + undeleted-file retry decisions |
|
|
41
|
+
| `Hermes::Molecules::HermesNotifications` | deterministic single-line notification texts |
|
|
42
|
+
| `Hermes::Organisms::HermesBox` | folder interface per channel: `publish`, `poll`, `ack`, `age`, `identical?` |
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
require "ace/hitl/hermes"
|
|
48
|
+
|
|
49
|
+
registry = Ace::Hitl::Hermes::Molecules::HermesChannels::Registry.new
|
|
50
|
+
registry.register("inbox", machine: "lab01", folder: "/run/lab/hermes/inbox")
|
|
51
|
+
registry.default = "inbox"
|
|
52
|
+
|
|
53
|
+
lines = []
|
|
54
|
+
box = Ace::Hitl::Hermes::Organisms::HermesBox.new(
|
|
55
|
+
channel: "inbox", registry: registry,
|
|
56
|
+
notifier: ->(line) { lines << line }
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Write the Captain's answer (atomic, no root, collision-safe):
|
|
60
|
+
message = box.publish(
|
|
61
|
+
kind: :answer, id: "m-123", body: "Ship it.",
|
|
62
|
+
sender: "captain", timestamp: "2026-09-24T10:00:00Z"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Consume (fail closed; invalid files are quarantined, never delivered):
|
|
66
|
+
result = box.poll
|
|
67
|
+
result.messages.each do |msg|
|
|
68
|
+
deliver(msg) # push to the target
|
|
69
|
+
box.ack(msg.id) # deletion IS the ACK
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
ace-test atoms # fast unit tests
|
|
77
|
+
ace-test # the whole package suite
|
|
78
|
+
```
|
data/Rakefile
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Hitl
|
|
5
|
+
module Hermes
|
|
6
|
+
module Atoms
|
|
7
|
+
# Token validation for every value that becomes part of a file
|
|
8
|
+
# name or an address (spec 8wm.t.vs1 §2): ids, senders, channel
|
|
9
|
+
# names, machine names. Path-traversal safe: no separators, no
|
|
10
|
+
# leading dot, bounded length.
|
|
11
|
+
module HermesTokens
|
|
12
|
+
PATTERN = /\A[A-Za-z0-9][A-Za-z0-9._:-]{0,63}\z/
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def valid?(value)
|
|
17
|
+
value.is_a?(String) && value.match?(PATTERN)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Fail closed: raises ContractError naming the label and the rule.
|
|
21
|
+
def validate!(value, label)
|
|
22
|
+
token = value.is_a?(String) ? value.strip : value
|
|
23
|
+
return token if valid?(token)
|
|
24
|
+
|
|
25
|
+
raise ContractError,
|
|
26
|
+
"hermes #{label} must match #{PATTERN.inspect} " \
|
|
27
|
+
"(1..64 chars; letters, digits, '.', '_', ':', '-'; " \
|
|
28
|
+
"no leading dot; got #{value.inspect})"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Hitl
|
|
5
|
+
module Hermes
|
|
6
|
+
# Error model for the folder-as-interface plugin (spec 8wm.t.vs1 §9).
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
# The shared folder or a configuration value violates the folder
|
|
10
|
+
# contract (missing folder, non-directory, world-writable folder,
|
|
11
|
+
# invalid channel/token values).
|
|
12
|
+
class ContractError < Error; end
|
|
13
|
+
|
|
14
|
+
# The plugin was invoked as root; writing without root is a pinned
|
|
15
|
+
# contract property and is refused fail closed.
|
|
16
|
+
class RootUserError < Error; end
|
|
17
|
+
|
|
18
|
+
# The target message file already exists; publish retries with a
|
|
19
|
+
# fresh id (bounded) instead of overwriting.
|
|
20
|
+
class CollisionError < Error; end
|
|
21
|
+
|
|
22
|
+
# The message declares a schema version this plugin does not support
|
|
23
|
+
# (only ace.hitl.hermes.message/v1); fail closed, quarantine upstream.
|
|
24
|
+
class UnknownFormatError < Error; end
|
|
25
|
+
|
|
26
|
+
# The message envelope violates schema ace.hitl.hermes.message/v1
|
|
27
|
+
# (bad filename/id pairing, missing or extra fields, bad sender,
|
|
28
|
+
# bad timestamp, empty body, wrong encoding or size).
|
|
29
|
+
class InvalidMessageError < Error; end
|
|
30
|
+
|
|
31
|
+
# The channel name is not in the registry.
|
|
32
|
+
class UnknownChannelError < Error; end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "hermes_contract"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Hitl
|
|
9
|
+
module Hermes
|
|
10
|
+
module Molecules
|
|
11
|
+
# Atomic write protocol (spec 8wm.t.vs1 §5): same-directory tmp
|
|
12
|
+
# file (0600) + fsync + chmod 0640 + rename(2) onto the target.
|
|
13
|
+
# Readers never observe partial content; no root privileges are
|
|
14
|
+
# required or used - running as root is refused fail closed.
|
|
15
|
+
module HermesAtomicWriter
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Writes `bytes` to `final_path` atomically. The target must not
|
|
19
|
+
# exist (CollisionError otherwise); tmp files are removed on
|
|
20
|
+
# every failure path. Returns the final path.
|
|
21
|
+
def write(final_path, bytes, euid_provider: -> { Process.euid })
|
|
22
|
+
if euid_provider.call.zero?
|
|
23
|
+
raise RootUserError,
|
|
24
|
+
"hermes writes without root: refusing atomic write as euid 0 " \
|
|
25
|
+
"(#{final_path})"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
folder = File.dirname(final_path)
|
|
29
|
+
HermesContract.verify_folder!(folder)
|
|
30
|
+
if File.exist?(final_path)
|
|
31
|
+
raise CollisionError, "hermes message file already exists: #{final_path}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
tmp_path = File.join(
|
|
35
|
+
folder,
|
|
36
|
+
"#{HermesContract::TMP_PREFIX}#{Process.pid}-#{SecureRandom.hex(8)}.tmp"
|
|
37
|
+
)
|
|
38
|
+
begin
|
|
39
|
+
File.open(tmp_path, File::WRONLY | File::CREAT | File::EXCL,
|
|
40
|
+
HermesContract::TMP_MODE) do |file|
|
|
41
|
+
file.write(bytes)
|
|
42
|
+
file.fsync
|
|
43
|
+
end
|
|
44
|
+
File.chmod(HermesContract::FILE_MODE, tmp_path)
|
|
45
|
+
File.rename(tmp_path, final_path)
|
|
46
|
+
rescue
|
|
47
|
+
File.delete(tmp_path) if File.exist?(tmp_path)
|
|
48
|
+
raise
|
|
49
|
+
end
|
|
50
|
+
final_path
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/hermes_tokens"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Hitl
|
|
7
|
+
module Hermes
|
|
8
|
+
module Molecules
|
|
9
|
+
module HermesChannels
|
|
10
|
+
# One shared folder between lab and hermes (spec 8wm.t.vs1 §2).
|
|
11
|
+
# `folder` is the absolute directory path; the message address
|
|
12
|
+
# is `<machine>/<name>/<id>` where `<name>` is the channel (and
|
|
13
|
+
# folder basename) token.
|
|
14
|
+
Channel = Struct.new(:name, :machine, :folder) do
|
|
15
|
+
def path
|
|
16
|
+
folder
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def address(id)
|
|
20
|
+
"#{machine}/#{name}/#{id}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The channel registry the plugin owns (rejestr kanalow).
|
|
25
|
+
# Token validation happens here, fail closed; folder existence
|
|
26
|
+
# and writability are re-verified at every folder operation by
|
|
27
|
+
# the Box (a channel may be registered before its folder
|
|
28
|
+
# exists).
|
|
29
|
+
class Registry
|
|
30
|
+
def initialize
|
|
31
|
+
@channels = {}
|
|
32
|
+
@default_name = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def register(name, machine:, folder:)
|
|
36
|
+
name = Atoms::HermesTokens.validate!(name, "channel name")
|
|
37
|
+
machine = Atoms::HermesTokens.validate!(machine, "machine name")
|
|
38
|
+
unless folder.is_a?(String) && File.absolute_path?(folder)
|
|
39
|
+
raise ContractError,
|
|
40
|
+
"hermes channel #{name} folder must be an absolute directory path " \
|
|
41
|
+
"(got #{folder.inspect})"
|
|
42
|
+
end
|
|
43
|
+
base = File.basename(folder)
|
|
44
|
+
unless Atoms::HermesTokens.valid?(base)
|
|
45
|
+
raise ContractError,
|
|
46
|
+
"hermes channel #{name} folder basename must match " \
|
|
47
|
+
"#{Atoms::HermesTokens::PATTERN.inspect} (got #{base.inspect})"
|
|
48
|
+
end
|
|
49
|
+
if @channels.key?(name)
|
|
50
|
+
raise ContractError, "hermes channel already registered: #{name}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@channels[name] = Channel.new(name: name, machine: machine, folder: folder)
|
|
54
|
+
name
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def resolve(name)
|
|
58
|
+
channel = @channels[name]
|
|
59
|
+
unless channel
|
|
60
|
+
raise UnknownChannelError,
|
|
61
|
+
"unknown hermes channel #{name.inspect} (available: #{available.join(", ")})"
|
|
62
|
+
end
|
|
63
|
+
channel
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def default=(name)
|
|
67
|
+
resolve(name)
|
|
68
|
+
@default_name = name
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def default
|
|
72
|
+
@default_name
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resolve_default
|
|
76
|
+
unless @default_name
|
|
77
|
+
raise ContractError, "no default hermes channel registered"
|
|
78
|
+
end
|
|
79
|
+
resolve(@default_name)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def available
|
|
83
|
+
@channels.keys.sort
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/hermes_tokens"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Hitl
|
|
7
|
+
module Hermes
|
|
8
|
+
module Molecules
|
|
9
|
+
# Versioned folder contract constants + folder-level rules
|
|
10
|
+
# (spec 8wm.t.vs1 §§1-5). The plugin implements contract version
|
|
11
|
+
# ace.hitl.hermes.folder/v1; the canonical shared-contract file is
|
|
12
|
+
# settled by A4 (8wm.t.vs2), the lab-config consumer side is
|
|
13
|
+
# 8wm.t.vp9 (separate repository).
|
|
14
|
+
module HermesContract
|
|
15
|
+
FOLDER_CONTRACT = "ace.hitl.hermes.folder/v1"
|
|
16
|
+
MESSAGE_SCHEMA = "ace.hitl.hermes.message/v1"
|
|
17
|
+
|
|
18
|
+
# Content bounds: UTF-8 only, at most 64 KiB per message file.
|
|
19
|
+
MAX_BYTES = 65_536
|
|
20
|
+
|
|
21
|
+
# Permissions: message files 0640, tmp files 0600 during the
|
|
22
|
+
# atomic write, quarantine directory 0750. No root, ever.
|
|
23
|
+
FILE_MODE = 0o640
|
|
24
|
+
TMP_MODE = 0o600
|
|
25
|
+
QUARANTINE_DIR_MODE = 0o750
|
|
26
|
+
|
|
27
|
+
MESSAGE_EXT = ".json"
|
|
28
|
+
TMP_PREFIX = ".hermes-tmp-"
|
|
29
|
+
QUARANTINE_DIR = ".quarantine"
|
|
30
|
+
REASON_EXT = ".reason.txt"
|
|
31
|
+
|
|
32
|
+
TMP_BASENAME_PREFIX = "." # every tmp file is a dotfile; poll never sees it
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# Fail-closed folder verification before EVERY folder operation:
|
|
37
|
+
# exists, is a directory, writable by the invoking user, and not
|
|
38
|
+
# world-writable.
|
|
39
|
+
def verify_folder!(path)
|
|
40
|
+
unless File.exist?(path)
|
|
41
|
+
raise ContractError, "hermes folder does not exist: #{path}"
|
|
42
|
+
end
|
|
43
|
+
unless File.directory?(path)
|
|
44
|
+
raise ContractError, "hermes folder is not a directory: #{path}"
|
|
45
|
+
end
|
|
46
|
+
unless File.writable?(path)
|
|
47
|
+
raise ContractError, "hermes folder is not writable by the invoking user: #{path}"
|
|
48
|
+
end
|
|
49
|
+
if File.stat(path).mode & 0o002 != 0
|
|
50
|
+
raise ContractError, "hermes folder must not be world-writable: #{path}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
path
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def file_name(id)
|
|
57
|
+
"#{Atoms::HermesTokens.validate!(id, "message id")}#{MESSAGE_EXT}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# `<token>.json` -> id token; anything else is not a message file.
|
|
61
|
+
def parse_file_name(name)
|
|
62
|
+
return nil unless name.is_a?(String) && name.end_with?(MESSAGE_EXT)
|
|
63
|
+
|
|
64
|
+
id = name.delete_suffix(MESSAGE_EXT)
|
|
65
|
+
Atoms::HermesTokens.valid?(id) ? id : nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def message_path(folder, id)
|
|
69
|
+
File.join(folder, file_name(id))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def quarantine_path(folder)
|
|
73
|
+
File.join(folder, QUARANTINE_DIR)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Shipped machine-readable contract artifact (JSON Schema
|
|
77
|
+
# draft-07) for the message envelope v1.
|
|
78
|
+
def schema_asset_path
|
|
79
|
+
File.expand_path("../schemas/message.v1.schema.json", __dir__)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "hermes_contract"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
module Hermes
|
|
9
|
+
module Molecules
|
|
10
|
+
# Message format registry (spec 8wm.t.vs1 §9): the decode gate
|
|
11
|
+
# every message file passes before any other validation. Supports
|
|
12
|
+
# exactly ace.hitl.hermes.message/v1; unknown versions and every
|
|
13
|
+
# encoding/size violation fail closed.
|
|
14
|
+
module HermesFormats
|
|
15
|
+
SUPPORTED = [HermesContract::MESSAGE_SCHEMA].freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def supported
|
|
20
|
+
SUPPORTED.dup
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# bytes -> parsed JSON object (Hash). Raises InvalidMessageError
|
|
24
|
+
# for size/encoding/JSON violations, UnknownFormatError for an
|
|
25
|
+
# unsupported or absent schema id.
|
|
26
|
+
def decode!(bytes)
|
|
27
|
+
unless bytes.bytesize <= HermesContract::MAX_BYTES
|
|
28
|
+
raise InvalidMessageError,
|
|
29
|
+
"message exceeds the #{HermesContract::MAX_BYTES}-byte size bound " \
|
|
30
|
+
"(#{bytes.bytesize} bytes)"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
content = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
34
|
+
unless content.valid_encoding?
|
|
35
|
+
raise InvalidMessageError, "message is not valid UTF-8"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
hash =
|
|
39
|
+
begin
|
|
40
|
+
JSON.parse(content)
|
|
41
|
+
rescue JSON::ParserError => e
|
|
42
|
+
raise InvalidMessageError, "message is not valid JSON: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
unless hash.is_a?(Hash)
|
|
45
|
+
raise InvalidMessageError, "message must be a JSON object"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
schema = hash["schema"]
|
|
49
|
+
unless SUPPORTED.include?(schema)
|
|
50
|
+
raise UnknownFormatError,
|
|
51
|
+
"unsupported message schema #{schema.inspect} (supported: " \
|
|
52
|
+
"#{SUPPORTED.join(", ")})"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
hash
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "../atoms/hermes_tokens"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Hitl
|
|
9
|
+
module Hermes
|
|
10
|
+
module Molecules
|
|
11
|
+
# Typed message envelope for schema ace.hitl.hermes.message/v1
|
|
12
|
+
# (spec 8wm.t.vs1 §3). One message per file; the payload id MUST
|
|
13
|
+
# equal the file name stem. Validation is fail closed and names
|
|
14
|
+
# every violated rule. The Captain's answer file shape is exactly
|
|
15
|
+
# {schema, id, kind: "answer", answer, sender, received_at}.
|
|
16
|
+
class HermesMessage
|
|
17
|
+
TIMESTAMP_PATTERN = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z\z/
|
|
18
|
+
KINDS = %w[question answer].freeze
|
|
19
|
+
|
|
20
|
+
EXPECTED_FIELDS = {
|
|
21
|
+
"question" => %w[schema id kind sender question created_at],
|
|
22
|
+
"answer" => %w[schema id kind sender answer received_at]
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :schema, :id, :kind, :sender, :body, :timestamp_field, :timestamp
|
|
26
|
+
|
|
27
|
+
def self.question(id:, question:, sender:, created_at:)
|
|
28
|
+
new(
|
|
29
|
+
id: id, kind: :question, sender: sender,
|
|
30
|
+
body_field: "question", body: question,
|
|
31
|
+
timestamp_field: "created_at", timestamp: created_at
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.answer(id:, answer:, sender:, received_at:)
|
|
36
|
+
new(
|
|
37
|
+
id: id, kind: :answer, sender: sender,
|
|
38
|
+
body_field: "answer", body: answer,
|
|
39
|
+
timestamp_field: "received_at", timestamp: received_at
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Fail-closed parse of an already-decoded envelope Hash.
|
|
44
|
+
# `filename_id` (when given) must equal the payload id.
|
|
45
|
+
def self.from_hash(hash, filename_id: nil)
|
|
46
|
+
# Defense-in-depth format gate (the Box always decodes via
|
|
47
|
+
# HermesFormats first): from_hash is public, so it pins the
|
|
48
|
+
# schema value itself instead of trusting its caller.
|
|
49
|
+
unless hash["schema"] == HermesContract::MESSAGE_SCHEMA
|
|
50
|
+
raise UnknownFormatError,
|
|
51
|
+
"unsupported message schema #{hash["schema"].inspect} (supported: " \
|
|
52
|
+
"#{HermesContract::MESSAGE_SCHEMA})"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
kind_name = hash["kind"]
|
|
56
|
+
unless KINDS.include?(kind_name)
|
|
57
|
+
raise InvalidMessageError,
|
|
58
|
+
"message kind must be one of #{KINDS.join(", ")} (got #{kind_name.inspect})"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
expected = EXPECTED_FIELDS.fetch(kind_name)
|
|
62
|
+
missing = expected - hash.keys
|
|
63
|
+
extra = hash.keys - expected
|
|
64
|
+
unless missing.empty? && extra.empty?
|
|
65
|
+
raise InvalidMessageError,
|
|
66
|
+
"message fields violate schema #{HermesContract::MESSAGE_SCHEMA} " \
|
|
67
|
+
"(missing: #{missing.empty? ? "none" : missing.join(", ")}; " \
|
|
68
|
+
"unexpected: #{extra.empty? ? "none" : extra.join(", ")})"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
id = Atoms::HermesTokens.validate!(hash["id"], "message id")
|
|
72
|
+
if filename_id && id != filename_id
|
|
73
|
+
raise InvalidMessageError,
|
|
74
|
+
"message id #{id.inspect} does not match file name stem #{filename_id.inspect}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
sender = Atoms::HermesTokens.validate!(hash["sender"], "sender")
|
|
78
|
+
|
|
79
|
+
body_field = (kind_name == "question") ? "question" : "answer"
|
|
80
|
+
body = hash[body_field]
|
|
81
|
+
unless body.is_a?(String) && !body.strip.empty?
|
|
82
|
+
raise InvalidMessageError, "message #{body_field} must be a non-empty string"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
timestamp_field = (kind_name == "question") ? "created_at" : "received_at"
|
|
86
|
+
timestamp = validate_timestamp!(hash[timestamp_field], timestamp_field)
|
|
87
|
+
|
|
88
|
+
new(
|
|
89
|
+
id: id, kind: kind_name.to_sym, sender: sender,
|
|
90
|
+
body_field: body_field, body: body,
|
|
91
|
+
timestamp_field: timestamp_field, timestamp: timestamp
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.validate_timestamp!(value, field)
|
|
96
|
+
match = value.is_a?(String) && TIMESTAMP_PATTERN.match(value)
|
|
97
|
+
unless match
|
|
98
|
+
raise InvalidMessageError,
|
|
99
|
+
"message #{field} must be UTC ISO-8601 YYYY-MM-DDTHH:MM:SSZ " \
|
|
100
|
+
"(got #{value.inspect})"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
begin
|
|
104
|
+
parsed = Time.iso8601(value)
|
|
105
|
+
rescue ArgumentError
|
|
106
|
+
raise InvalidMessageError,
|
|
107
|
+
"message #{field} is not a real calendar timestamp: #{value.inspect}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Time.iso8601 silently rolls impossible components (Feb 30,
|
|
111
|
+
# Apr 31, hour 24) over into the next real instant; the
|
|
112
|
+
# contract requires the literal calendar date/time, so the
|
|
113
|
+
# parsed value must round-trip to the source components.
|
|
114
|
+
source = match.captures.map(&:to_i)
|
|
115
|
+
round_trip = [parsed.year, parsed.month, parsed.day,
|
|
116
|
+
parsed.hour, parsed.min, parsed.sec]
|
|
117
|
+
unless round_trip == source
|
|
118
|
+
raise InvalidMessageError,
|
|
119
|
+
"message #{field} is not a real calendar timestamp: #{value.inspect}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
value
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def initialize(id:, kind:, sender:, body_field:, body:, timestamp_field:, timestamp:)
|
|
126
|
+
@schema = HermesContract::MESSAGE_SCHEMA
|
|
127
|
+
@id = id
|
|
128
|
+
@kind = kind
|
|
129
|
+
@sender = sender
|
|
130
|
+
@body_field = body_field
|
|
131
|
+
@body = body
|
|
132
|
+
@timestamp_field = timestamp_field
|
|
133
|
+
@timestamp = timestamp
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def question?
|
|
137
|
+
@kind == :question
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def answer?
|
|
141
|
+
@kind == :answer
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def to_h
|
|
145
|
+
{
|
|
146
|
+
"schema" => @schema,
|
|
147
|
+
"id" => @id,
|
|
148
|
+
"kind" => @kind.to_s,
|
|
149
|
+
"sender" => @sender,
|
|
150
|
+
@body_field => @body,
|
|
151
|
+
@timestamp_field => @timestamp
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Canonical serialization: fixed key order, compact JSON.
|
|
156
|
+
def to_json(*)
|
|
157
|
+
JSON.generate(to_h)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Hitl
|
|
5
|
+
module Hermes
|
|
6
|
+
module Molecules
|
|
7
|
+
# Deterministic single-line notification texts (spec 8wm.t.vs1 §9):
|
|
8
|
+
# no clock, no colors, only provided values. The address is the
|
|
9
|
+
# message address `<machine>/<folder>/<id>`. Emission is an
|
|
10
|
+
# injectable sink (default silent in Box).
|
|
11
|
+
module HermesNotifications
|
|
12
|
+
EVENTS = %w[
|
|
13
|
+
question_received
|
|
14
|
+
answer_written
|
|
15
|
+
delivered
|
|
16
|
+
acked
|
|
17
|
+
quarantined
|
|
18
|
+
retry_scheduled
|
|
19
|
+
retry_exhausted
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Builds the notification line for an event. Unknown events fail
|
|
25
|
+
# closed so a typo cannot silently drop notifications.
|
|
26
|
+
def emit(event, address:, **details)
|
|
27
|
+
case event.to_s
|
|
28
|
+
when "question_received"
|
|
29
|
+
"hermes: question #{address} received"
|
|
30
|
+
when "answer_written"
|
|
31
|
+
"hermes: answer #{address} written"
|
|
32
|
+
when "delivered"
|
|
33
|
+
"hermes: #{address} delivered"
|
|
34
|
+
when "acked"
|
|
35
|
+
"hermes: #{address} acked (file deleted)"
|
|
36
|
+
when "quarantined"
|
|
37
|
+
"hermes: #{address} quarantined (#{details[:reason]})"
|
|
38
|
+
when "retry_scheduled"
|
|
39
|
+
"hermes: #{address} retry #{details[:attempt]}/#{details[:max_attempts]} " \
|
|
40
|
+
"(#{details[:policy]})"
|
|
41
|
+
when "retry_exhausted"
|
|
42
|
+
"hermes: #{address} retries exhausted (#{details[:reason]})"
|
|
43
|
+
else
|
|
44
|
+
raise ContractError,
|
|
45
|
+
"unknown hermes notification event #{event.inspect} " \
|
|
46
|
+
"(known: #{EVENTS.join(", ")})"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "hermes_contract"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
module Hermes
|
|
9
|
+
module Molecules
|
|
10
|
+
# Quarantine for invalid message files (spec 8wm.t.vs1 §7): the
|
|
11
|
+
# file is moved atomically into <folder>/.quarantine/ (0750) with
|
|
12
|
+
# a <name>.reason.txt sidecar (0640). Quarantined content is never
|
|
13
|
+
# rewritten, re-validated, or delivered. Like every hermes write
|
|
14
|
+
# path, running as root is refused fail closed.
|
|
15
|
+
module HermesQuarantine
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Moves `path` (inside `folder`) into the quarantine directory
|
|
19
|
+
# and writes the reason sidecar. Returns the quarantined path.
|
|
20
|
+
def move(folder, path, reason:, now: -> { Time.now },
|
|
21
|
+
euid_provider: -> { Process.euid })
|
|
22
|
+
if euid_provider.call.zero?
|
|
23
|
+
raise RootUserError,
|
|
24
|
+
"hermes writes without root: refusing quarantine move as euid 0 " \
|
|
25
|
+
"(#{path})"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
quarantine_dir = HermesContract.quarantine_path(folder)
|
|
29
|
+
unless File.directory?(quarantine_dir)
|
|
30
|
+
Dir.mkdir(quarantine_dir, HermesContract::QUARANTINE_DIR_MODE)
|
|
31
|
+
# Modes are FORCED, not inherited from the umask (mirrors
|
|
32
|
+
# HermesAtomicWriter): the pinned 0750 dir contract holds
|
|
33
|
+
# under any umask.
|
|
34
|
+
File.chmod(HermesContract::QUARANTINE_DIR_MODE, quarantine_dir)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
base = File.basename(path)
|
|
38
|
+
dest = File.join(quarantine_dir, base)
|
|
39
|
+
suffix = 0
|
|
40
|
+
while File.exist?(dest)
|
|
41
|
+
suffix += 1
|
|
42
|
+
dest = File.join(quarantine_dir, "#{base}.#{suffix}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sidecar_path = "#{dest}#{HermesContract::REASON_EXT}"
|
|
46
|
+
File.rename(path, dest)
|
|
47
|
+
File.open(sidecar_path,
|
|
48
|
+
File::WRONLY | File::CREAT | File::EXCL,
|
|
49
|
+
HermesContract::FILE_MODE) do |sidecar|
|
|
50
|
+
sidecar.write("#{now.call.utc.strftime("%Y-%m-%dT%H:%M:%SZ")} #{reason}\n")
|
|
51
|
+
end
|
|
52
|
+
# The open-mode is umask-sensitive; force the pinned 0640.
|
|
53
|
+
File.chmod(HermesContract::FILE_MODE, sidecar_path)
|
|
54
|
+
dest
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Hitl
|
|
5
|
+
module Hermes
|
|
6
|
+
module Molecules
|
|
7
|
+
# Pure retry decisions (spec 8wm.t.vs1 §8). The clock-and-loop
|
|
8
|
+
# belongs to the transport role (y24 / A4); this module pins the
|
|
9
|
+
# policy so both sides decide identically.
|
|
10
|
+
#
|
|
11
|
+
# - collision retries (write time): bounded fresh-id attempts;
|
|
12
|
+
# - undeleted-file retries (post-delivery): a delivered file that
|
|
13
|
+
# is not ACKed after the stale deadline is redelivered as
|
|
14
|
+
# IDENTICAL bytes, bounded, then quarantined (terminal).
|
|
15
|
+
module HermesRetryPolicy
|
|
16
|
+
DEFAULT_MAX_ATTEMPTS = 3
|
|
17
|
+
DEFAULT_BASE_DELAY = 1.0
|
|
18
|
+
DEFAULT_MAX_DELAY = 60.0
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def collision_retry?(failed_attempts, max_attempts: DEFAULT_MAX_ATTEMPTS)
|
|
22
|
+
failed_attempts < max_attempts
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Exponential backoff for the given 1-based attempt number.
|
|
26
|
+
def delay_for(attempt, base_delay: DEFAULT_BASE_DELAY, max_delay: DEFAULT_MAX_DELAY)
|
|
27
|
+
[base_delay * (2**(attempt - 1)), max_delay].min
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Decision for a delivered-but-not-acked file.
|
|
31
|
+
# `attempts` = deliveries already performed (>= 1).
|
|
32
|
+
def undeleted_decision(age_seconds:, stale_after:, attempts:, max_attempts: DEFAULT_MAX_ATTEMPTS)
|
|
33
|
+
return :wait if age_seconds < stale_after
|
|
34
|
+
return :redeliver if attempts < max_attempts
|
|
35
|
+
|
|
36
|
+
:quarantine
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require_relative "../molecules/hermes_contract"
|
|
6
|
+
require_relative "../molecules/hermes_formats"
|
|
7
|
+
require_relative "../molecules/hermes_message"
|
|
8
|
+
require_relative "../molecules/hermes_atomic_writer"
|
|
9
|
+
require_relative "../molecules/hermes_quarantine"
|
|
10
|
+
require_relative "../molecules/hermes_retry_policy"
|
|
11
|
+
require_relative "../molecules/hermes_notifications"
|
|
12
|
+
require_relative "../molecules/hermes_channels"
|
|
13
|
+
|
|
14
|
+
module Ace
|
|
15
|
+
module Hitl
|
|
16
|
+
module Hermes
|
|
17
|
+
module Organisms
|
|
18
|
+
# The folder interface per channel (spec 8wm.t.vs1 §9): publish
|
|
19
|
+
# (atomic write + collision retry), poll (validate + quarantine),
|
|
20
|
+
# ack (deletion IS the ACK), age (retry clock), identical?
|
|
21
|
+
# (redelivery identity). The Box is role-agnostic: the hermes
|
|
22
|
+
# side polls for questions, the lab side (labd) polls for answers.
|
|
23
|
+
class HermesBox
|
|
24
|
+
QuarantinedFile = Struct.new(:path, :reason)
|
|
25
|
+
PollResult = Struct.new(:messages, :quarantined)
|
|
26
|
+
|
|
27
|
+
attr_reader :channel
|
|
28
|
+
|
|
29
|
+
def initialize(channel:, registry: nil, id_generator: -> { SecureRandom.hex(6) },
|
|
30
|
+
notifier: nil, euid_provider: -> { Process.euid })
|
|
31
|
+
@channel =
|
|
32
|
+
if channel.is_a?(Molecules::HermesChannels::Channel)
|
|
33
|
+
channel
|
|
34
|
+
elsif registry
|
|
35
|
+
registry.resolve(channel)
|
|
36
|
+
else
|
|
37
|
+
raise ContractError,
|
|
38
|
+
"hermes box needs a Channel or a registry to resolve the channel name"
|
|
39
|
+
end
|
|
40
|
+
@id_generator = id_generator
|
|
41
|
+
@notifier = notifier || ->(_line) {}
|
|
42
|
+
@euid_provider = euid_provider
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def address(id)
|
|
46
|
+
@channel.address(id)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Atomic write of a validated message. A GENERATED id is
|
|
50
|
+
# regenerated (bounded, notified) on a filename collision; an
|
|
51
|
+
# EXPLICITLY supplied id never gets regenerated - it asserts
|
|
52
|
+
# correlation (e.g. the answer of question `q-1` is `q-1.json`),
|
|
53
|
+
# so a collision there fails loudly instead of silently breaking
|
|
54
|
+
# the question -> answer pairing. Returns the published Message.
|
|
55
|
+
def publish(kind:, body:, sender:, timestamp:, id: nil)
|
|
56
|
+
explicit_id = !id.nil?
|
|
57
|
+
attempts = 0
|
|
58
|
+
loop do
|
|
59
|
+
message = build_message(
|
|
60
|
+
kind: kind, id: id || @id_generator.call,
|
|
61
|
+
sender: sender, timestamp: timestamp, body: body
|
|
62
|
+
)
|
|
63
|
+
# Producer fail-closed gate: the exact BYTES about to be
|
|
64
|
+
# written must survive the consumers' decode gate (UTF-8 +
|
|
65
|
+
# 64 KiB cap + schema id), and the decoded envelope must
|
|
66
|
+
# satisfy the exact message contract, BEFORE any disk
|
|
67
|
+
# write - so an envelope its own poll would terminally
|
|
68
|
+
# quarantine can never be published (review 8wq2zttx on
|
|
69
|
+
# PR#336).
|
|
70
|
+
envelope = message.to_json
|
|
71
|
+
Molecules::HermesFormats.decode!(envelope)
|
|
72
|
+
Molecules::HermesMessage.from_hash(
|
|
73
|
+
JSON.parse(envelope), filename_id: message.id
|
|
74
|
+
)
|
|
75
|
+
begin
|
|
76
|
+
Molecules::HermesAtomicWriter.write(
|
|
77
|
+
Molecules::HermesContract.message_path(@channel.folder, message.id),
|
|
78
|
+
envelope,
|
|
79
|
+
euid_provider: @euid_provider
|
|
80
|
+
)
|
|
81
|
+
rescue CollisionError
|
|
82
|
+
raise if explicit_id
|
|
83
|
+
|
|
84
|
+
attempts += 1
|
|
85
|
+
unless Molecules::HermesRetryPolicy.collision_retry?(attempts)
|
|
86
|
+
raise
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
notify(:retry_scheduled, address(message.id), attempt: attempts,
|
|
90
|
+
max_attempts: Molecules::HermesRetryPolicy::DEFAULT_MAX_ATTEMPTS,
|
|
91
|
+
policy: "collision")
|
|
92
|
+
id = nil # force a fresh id on the next iteration
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
notify(:answer_written, address(message.id)) if message.answer?
|
|
97
|
+
return message
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Validate + collect everything currently in the folder.
|
|
102
|
+
# Valid files become Messages; files failing the fail-closed
|
|
103
|
+
# gate are quarantined (never delivered). Foreign names
|
|
104
|
+
# (dotfiles, tmp leftovers, non-`<token>.json`) are ignored.
|
|
105
|
+
def poll
|
|
106
|
+
Molecules::HermesContract.verify_folder!(@channel.folder)
|
|
107
|
+
messages = []
|
|
108
|
+
quarantined = []
|
|
109
|
+
|
|
110
|
+
Dir.children(@channel.folder).sort.each do |name|
|
|
111
|
+
id = Molecules::HermesContract.parse_file_name(name)
|
|
112
|
+
next if id.nil? # dotfiles, tmp leftovers, foreign names
|
|
113
|
+
|
|
114
|
+
path = File.join(@channel.folder, name)
|
|
115
|
+
# Opened with O_NOFOLLOW and verified regular on the open
|
|
116
|
+
# descriptor: a top-level symlink must never be followed -
|
|
117
|
+
# it could import content from outside the channel or
|
|
118
|
+
# reverse a quarantine's terminal state (review 8wq2ztu3
|
|
119
|
+
# on PR#336).
|
|
120
|
+
begin
|
|
121
|
+
file = File.open(path, File::RDONLY | File::NOFOLLOW)
|
|
122
|
+
rescue Errno::ENOENT
|
|
123
|
+
next # concurrently acked between listing and reading
|
|
124
|
+
rescue Errno::ELOOP
|
|
125
|
+
quarantined << quarantine(path, id, "message file is a symlink")
|
|
126
|
+
next
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
begin
|
|
130
|
+
begin
|
|
131
|
+
regular = file.stat.file?
|
|
132
|
+
rescue Errno::ENOENT
|
|
133
|
+
next # concurrently acked after the open
|
|
134
|
+
end
|
|
135
|
+
unless regular
|
|
136
|
+
quarantined << quarantine(path, id, "message file is not a regular file")
|
|
137
|
+
next
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
bytes = file.read(Molecules::HermesContract::MAX_BYTES + 1) || ""
|
|
141
|
+
hash = Molecules::HermesFormats.decode!(bytes)
|
|
142
|
+
message = Molecules::HermesMessage.from_hash(hash, filename_id: id)
|
|
143
|
+
rescue Error => e
|
|
144
|
+
quarantined << quarantine(path, id, e.message)
|
|
145
|
+
next
|
|
146
|
+
ensure
|
|
147
|
+
file.close
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
notify(:question_received, address(message.id)) if message.question?
|
|
151
|
+
messages << message
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
PollResult.new(messages: messages, quarantined: quarantined)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Deletion is the ACK; idempotent (an absent file reports
|
|
158
|
+
# :already_acked, never an error). The delete is a folder write,
|
|
159
|
+
# so the no-root gate applies exactly like on publish.
|
|
160
|
+
def ack(id)
|
|
161
|
+
if @euid_provider.call.zero?
|
|
162
|
+
raise RootUserError,
|
|
163
|
+
"hermes writes without root: refusing ack deletion as euid 0 " \
|
|
164
|
+
"(#{Molecules::HermesContract.message_path(@channel.folder, id)})"
|
|
165
|
+
end
|
|
166
|
+
Molecules::HermesContract.verify_folder!(@channel.folder)
|
|
167
|
+
path = Molecules::HermesContract.message_path(@channel.folder, id)
|
|
168
|
+
if File.exist?(path)
|
|
169
|
+
File.delete(path)
|
|
170
|
+
notify(:acked, address(id))
|
|
171
|
+
:acked
|
|
172
|
+
else
|
|
173
|
+
:already_acked
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Seconds since the message file was written (mtime); nil when
|
|
178
|
+
# the file is gone (already acked). The undeleted-file retry
|
|
179
|
+
# clock (spec §8).
|
|
180
|
+
def age(id)
|
|
181
|
+
path = Molecules::HermesContract.message_path(@channel.folder, id)
|
|
182
|
+
File.exist?(path) ? Time.now - File.mtime(path) : nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# True when the file for `id` currently holds exactly `bytes` -
|
|
186
|
+
# the identity check every redelivery must pass (retries never
|
|
187
|
+
# duplicate answers).
|
|
188
|
+
def identical?(id, bytes)
|
|
189
|
+
path = Molecules::HermesContract.message_path(@channel.folder, id)
|
|
190
|
+
File.exist?(path) && read_capped(path) == bytes
|
|
191
|
+
rescue Errno::ENOENT
|
|
192
|
+
false
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
def build_message(kind:, id:, sender:, timestamp:, body:)
|
|
198
|
+
case kind.to_s
|
|
199
|
+
when "question"
|
|
200
|
+
Molecules::HermesMessage.question(
|
|
201
|
+
id: id, question: body, sender: sender, created_at: timestamp
|
|
202
|
+
)
|
|
203
|
+
when "answer"
|
|
204
|
+
Molecules::HermesMessage.answer(
|
|
205
|
+
id: id, answer: body, sender: sender, received_at: timestamp
|
|
206
|
+
)
|
|
207
|
+
else
|
|
208
|
+
raise ContractError,
|
|
209
|
+
"hermes message kind must be question or answer (got #{kind.inspect})"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Read at most MAX_BYTES + 1 bytes so an oversized file fails
|
|
214
|
+
# the size gate without reading the whole file. An empty file
|
|
215
|
+
# reads as "" (File.read with an explicit length returns nil at
|
|
216
|
+
# EOF) so the empty payload still fails the formats gate and is
|
|
217
|
+
# quarantined instead of crashing the poll loop.
|
|
218
|
+
def read_capped(path)
|
|
219
|
+
File.read(path, Molecules::HermesContract::MAX_BYTES + 1) || ""
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def quarantine(path, id, reason)
|
|
223
|
+
quarantined = Molecules::HermesQuarantine.move(
|
|
224
|
+
@channel.folder, path, reason: reason,
|
|
225
|
+
euid_provider: @euid_provider
|
|
226
|
+
)
|
|
227
|
+
notify(:quarantined, address(id), reason: reason)
|
|
228
|
+
QuarantinedFile.new(path: quarantined, reason: reason)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def notify(event, address, **details)
|
|
232
|
+
@notifier.call(
|
|
233
|
+
Molecules::HermesNotifications.emit(event, address: address, **details)
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "ace.hitl.hermes.message/v1",
|
|
4
|
+
"title": "ace-hitl-hermes message envelope v1",
|
|
5
|
+
"description": "One JSON object per file in the shared lab <-> hermes folder (folder contract ace.hitl.hermes.folder/v1, spec 8wm.t.vs1). File name must be <id>.json with id equal to the payload id.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["schema", "id", "kind", "sender"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema": {
|
|
11
|
+
"const": "ace.hitl.hermes.message/v1"
|
|
12
|
+
},
|
|
13
|
+
"id": {
|
|
14
|
+
"$ref": "#/definitions/token"
|
|
15
|
+
},
|
|
16
|
+
"sender": {
|
|
17
|
+
"$ref": "#/definitions/token"
|
|
18
|
+
},
|
|
19
|
+
"kind": {
|
|
20
|
+
"enum": ["question", "answer"]
|
|
21
|
+
},
|
|
22
|
+
"question": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"minLength": 1,
|
|
25
|
+
"pattern": "\\S",
|
|
26
|
+
"description": "Required for kind=question; non-empty after strip."
|
|
27
|
+
},
|
|
28
|
+
"answer": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"minLength": 1,
|
|
31
|
+
"pattern": "\\S",
|
|
32
|
+
"description": "Required for kind=answer (the Captain's answer); non-empty after strip."
|
|
33
|
+
},
|
|
34
|
+
"created_at": {
|
|
35
|
+
"$ref": "#/definitions/timestamp",
|
|
36
|
+
"description": "Required for kind=question."
|
|
37
|
+
},
|
|
38
|
+
"received_at": {
|
|
39
|
+
"$ref": "#/definitions/timestamp",
|
|
40
|
+
"description": "Required for kind=answer."
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"definitions": {
|
|
44
|
+
"token": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$"
|
|
47
|
+
},
|
|
48
|
+
"timestamp": {
|
|
49
|
+
"type": "string",
|
|
50
|
+
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$",
|
|
51
|
+
"format": "date-time"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"oneOf": [
|
|
55
|
+
{
|
|
56
|
+
"properties": {
|
|
57
|
+
"kind": {
|
|
58
|
+
"const": "question"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"required": ["question", "created_at"],
|
|
62
|
+
"not": {
|
|
63
|
+
"anyOf": [
|
|
64
|
+
{ "required": ["answer"] },
|
|
65
|
+
{ "required": ["received_at"] }
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"properties": {
|
|
71
|
+
"kind": {
|
|
72
|
+
"const": "answer"
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"required": ["answer", "received_at"],
|
|
76
|
+
"not": {
|
|
77
|
+
"anyOf": [
|
|
78
|
+
{ "required": ["question"] },
|
|
79
|
+
{ "required": ["created_at"] }
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "hermes/version"
|
|
4
|
+
require_relative "hermes/errors"
|
|
5
|
+
require_relative "hermes/atoms/hermes_tokens"
|
|
6
|
+
require_relative "hermes/molecules/hermes_contract"
|
|
7
|
+
require_relative "hermes/molecules/hermes_formats"
|
|
8
|
+
require_relative "hermes/molecules/hermes_message"
|
|
9
|
+
require_relative "hermes/molecules/hermes_atomic_writer"
|
|
10
|
+
require_relative "hermes/molecules/hermes_quarantine"
|
|
11
|
+
require_relative "hermes/molecules/hermes_retry_policy"
|
|
12
|
+
require_relative "hermes/molecules/hermes_notifications"
|
|
13
|
+
require_relative "hermes/molecules/hermes_channels"
|
|
14
|
+
require_relative "hermes/organisms/hermes_box"
|
|
15
|
+
|
|
16
|
+
module Ace
|
|
17
|
+
module Hitl
|
|
18
|
+
# Folder-as-interface HITL transport plugin (spec 8wm.t.vs1): the
|
|
19
|
+
# shared folder between lab and hermes IS the transport. A message is
|
|
20
|
+
# a file, the address is <machine>/<folder>/<id>, delivery is push,
|
|
21
|
+
# and ACK is the deletion of the file after delivery. The plugin owns
|
|
22
|
+
# the channel registry, the notification texts, and the message
|
|
23
|
+
# formats (ace.hitl.hermes.message/v1).
|
|
24
|
+
module Hermes
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ace-hitl-hermes
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Michal Czyz
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-09-27 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ace-support-test-helpers
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.14'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.14'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.19'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.19'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rake
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '13.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '13.0'
|
|
54
|
+
description: 'ace-hitl-hermes treats the shared lab <-> hermes folder as the HITL
|
|
55
|
+
transport: a message is a file, the address is <machine>/<folder>/<id>, delivery
|
|
56
|
+
is push, and ACK is deletion. The plugin owns the channel registry, notification
|
|
57
|
+
texts, and the versioned message formats (ace.hitl.hermes.message/v1), with atomic
|
|
58
|
+
same-directory tmp+rename writes, fail-closed validation, quarantine, and bounded
|
|
59
|
+
retries.'
|
|
60
|
+
email:
|
|
61
|
+
- mc@cs3b.com
|
|
62
|
+
executables: []
|
|
63
|
+
extensions: []
|
|
64
|
+
extra_rdoc_files: []
|
|
65
|
+
files:
|
|
66
|
+
- CHANGELOG.md
|
|
67
|
+
- README.md
|
|
68
|
+
- Rakefile
|
|
69
|
+
- lib/ace/hitl/hermes.rb
|
|
70
|
+
- lib/ace/hitl/hermes/atoms/hermes_tokens.rb
|
|
71
|
+
- lib/ace/hitl/hermes/errors.rb
|
|
72
|
+
- lib/ace/hitl/hermes/molecules/hermes_atomic_writer.rb
|
|
73
|
+
- lib/ace/hitl/hermes/molecules/hermes_channels.rb
|
|
74
|
+
- lib/ace/hitl/hermes/molecules/hermes_contract.rb
|
|
75
|
+
- lib/ace/hitl/hermes/molecules/hermes_formats.rb
|
|
76
|
+
- lib/ace/hitl/hermes/molecules/hermes_message.rb
|
|
77
|
+
- lib/ace/hitl/hermes/molecules/hermes_notifications.rb
|
|
78
|
+
- lib/ace/hitl/hermes/molecules/hermes_quarantine.rb
|
|
79
|
+
- lib/ace/hitl/hermes/molecules/hermes_retry_policy.rb
|
|
80
|
+
- lib/ace/hitl/hermes/organisms/hermes_box.rb
|
|
81
|
+
- lib/ace/hitl/hermes/schemas/message.v1.schema.json
|
|
82
|
+
- lib/ace/hitl/hermes/version.rb
|
|
83
|
+
homepage: https://github.com/cs3b/ace
|
|
84
|
+
licenses:
|
|
85
|
+
- MIT
|
|
86
|
+
metadata:
|
|
87
|
+
allowed_push_host: https://rubygems.org
|
|
88
|
+
homepage_uri: https://github.com/cs3b/ace
|
|
89
|
+
source_code_uri: https://github.com/cs3b/ace/tree/main/ace-hitl-hermes/
|
|
90
|
+
changelog_uri: https://github.com/cs3b/ace/blob/main/ace-hitl-hermes/CHANGELOG.md
|
|
91
|
+
rdoc_options: []
|
|
92
|
+
require_paths:
|
|
93
|
+
- lib
|
|
94
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
95
|
+
requirements:
|
|
96
|
+
- - ">="
|
|
97
|
+
- !ruby/object:Gem::Version
|
|
98
|
+
version: 3.2.0
|
|
99
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0'
|
|
104
|
+
requirements: []
|
|
105
|
+
rubygems_version: 3.6.9
|
|
106
|
+
specification_version: 4
|
|
107
|
+
summary: Folder-as-interface HITL transport plugin for the hermes relay
|
|
108
|
+
test_files: []
|