Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b92a669ddc | |||
| bb29b001e4 | |||
| f0ff25221e | |||
| 780cb342ad | |||
| 5c563f02c8 | |||
| ad593c9bb9 | |||
| 736fd9cf4b | |||
| 05244a82b3 | |||
| ef4996a01e | |||
| edbd8d816a | |||
| 9425a9b696 | |||
| d0688c8a60 | |||
| 2c467c2553 | |||
| c6430d8edd | |||
| bfee23acc3 | |||
| 7dec74f1b4 | |||
| 482598e2a6 | |||
| 5f5d16fbd4 | |||
| 7df7985a16 | |||
| 724b35b46e | |||
| 2eb2d6112e | |||
| 7c458e8bf2 | |||
| 133f03e428 | |||
| 804279175d | |||
| 9dea289975 | |||
| cb4a6869b9 | |||
| 28b45d97e5 | |||
| 1a397e962e | |||
| 209e1231ea | |||
| 5d8b9d365c | |||
| a6aeda39e7 | |||
| 31b3c24caa | |||
| d105da978d | |||
| 5051a06443 | |||
| a134eccc57 | |||
| 6f275227d2 | |||
| 283ccf8423 | |||
| b96fba4a03 | |||
| 6d97d210b4 | |||
| 37b23cd704 | |||
| 367facf6a6 | |||
| 7f9a9c09f9 | |||
| e854957247 | |||
| b4b7cf5155 | |||
| cbb35ad947 | |||
| 4b4a8688c2 | |||
| 7e48d4b86c | |||
| 41cc785534 | |||
| 60fa86a107 | |||
| a5d6ce1a37 | |||
| a52ca35d34 |
@@ -59,7 +59,7 @@ as `matches HEAD`, `drift`, or `unknown`; do not turn an unclear timestamp into
|
|||||||
Report the process identifier (PID) and uptime too:
|
Report the process identifier (PID) and uptime too:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PIDS="$(pgrep -f 'target/fleetd.jar' || true)"
|
PIDS="$(pgrep -f 'fleetd.jar' || true)"
|
||||||
if [ -z "$PIDS" ]; then
|
if [ -z "$PIDS" ]; then
|
||||||
printf '%s\n' 'fleetd: not running'
|
printf '%s\n' 'fleetd: not running'
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -164,6 +164,13 @@ fails.
|
|||||||
- **`accepted` does not mean your pane has been cleared.** It means every gate passed and the roll
|
- **`accepted` does not mean your pane has been cleared.** It means every gate passed and the roll
|
||||||
is scheduled to run once your current turn ends. Say your goodbye in the same turn — you will not
|
is scheduled to run once your current turn ends. Say your goodbye in the same turn — you will not
|
||||||
get another one.
|
get another one.
|
||||||
|
- **If you are still running after that turn, the roll did not happen.** A roll that works clears
|
||||||
|
you, so surviving your own goodbye is itself the signal that it refused. Check with
|
||||||
|
`fleet_handover{action: "status", token}`, using the token you confirmed. `TURN_NEVER_SETTLED`
|
||||||
|
means your turn ran past `leadRollover.turnSettleSeconds` and **no `/clear` was ever sent**: your
|
||||||
|
context is intact and nothing was lost. Open a fresh request and retry. Never assume the roll
|
||||||
|
succeeded because `confirm` answered `accepted` — by the time it refuses, there is no caller left
|
||||||
|
to tell, so this check is the only thing that closes that gap.
|
||||||
- **There is no terminal or session parameter, on purpose.** The pane is always your own, resolved
|
- **There is no terminal or session parameter, on purpose.** The pane is always your own, resolved
|
||||||
from your connection, so you can only ever roll yourself.
|
from your connection, so you can only ever roll yourself.
|
||||||
- **`operatorConfirmed` is your report of what a human told you.** Do not pass `true` because you
|
- **`operatorConfirmed` is your report of what a human told you.** Do not pass `true` because you
|
||||||
|
|||||||
@@ -22,13 +22,22 @@ scripts/redeploy-fleetd.sh --yes # skip the drain prompt (fleet already chec
|
|||||||
scripts/redeploy-fleetd.sh --no-build # restart the jar already on disk
|
scripts/redeploy-fleetd.sh --no-build # restart the jar already on disk
|
||||||
```
|
```
|
||||||
|
|
||||||
`--no-build` skips the build and restarts whatever jar is at `fleetd/target/fleetd.jar`. Use it only
|
`--no-build` skips the build and restarts whatever jar is at `fleetd/run/fleetd.jar` — the runtime
|
||||||
when you just built and nothing changed since. It gives up the protection in the next paragraph: no
|
path, not Maven's output path. Use it only when you just built and nothing changed since. It gives
|
||||||
build runs, so a stale or missing jar is not caught early. The script still checks the file is there
|
up the protection in the next paragraph: no build runs, so a stale or missing jar is not caught
|
||||||
and dies with `no jar at … — run without --no-build` if it is not, but it cannot tell you the jar is
|
early. The script still checks the file is there and dies with `no jar at … — run without
|
||||||
old. A `mvn clean` in the tree deletes that jar while the daemon keeps running on it, and nothing
|
--no-build` if it is not, but it cannot tell you the jar is old.
|
||||||
degrades until the next restart. Run `--check` first: it prints the jar's hash and its modification
|
|
||||||
time, so you can see for yourself whether the jar is missing or older than the code you mean to ship.
|
The daemon runs from `fleetd/run/fleetd.jar`, not from `fleetd/target/fleetd.jar` where Maven
|
||||||
|
writes its output (fleetd #664). That split is what makes a bare `mvn install`/`mvn clean` in the
|
||||||
|
main clone harmless now: neither can reach the file the running daemon holds open, because that
|
||||||
|
file no longer lives under `target/` at all. Verify a merge by building in a throwaway git
|
||||||
|
worktree anyway — a build still produces nothing the fleet runs until this script's own `mv` of
|
||||||
|
`target/fleetd.jar` onto `run/fleetd.jar`, performed only after the old daemon is confirmed gone.
|
||||||
|
Let only `scripts/redeploy-fleetd.sh` touch `fleetd/run/fleetd.jar`. Run `--check` first: it prints
|
||||||
|
the BUILT jar (`target/fleetd.jar`) and the RUNNING jar (`run/fleetd.jar`) as two separately
|
||||||
|
labelled hash-and-mtime facts, so a mismatch between them — a build sitting unswapped, or a stale
|
||||||
|
runtime jar — is visible before you decide anything.
|
||||||
|
|
||||||
It builds before it stops anything, so a failed build never leaves the fleet down; it waits for the
|
It builds before it stops anything, so a failed build never leaves the fleet down; it waits for the
|
||||||
old process to exit rather than assuming; it polls `/healthz`; and it anchors its log checks to a
|
old process to exit rather than assuming; it polls `/healthz`; and it anchors its log checks to a
|
||||||
|
|||||||
@@ -323,6 +323,13 @@ must obey belongs in the charter, not here.
|
|||||||
reference**, with the intent→tool table above as the short form. `McpContractDocTest` fails if
|
reference**, with the intent→tool table above as the short form. `McpContractDocTest` fails if
|
||||||
that page names a `fleet_*` tool the server does not register. The flows are kept out of this
|
that page names a `fleet_*` tool the server does not register. The flows are kept out of this
|
||||||
file because this file loads into every session's context.
|
file because this file loads into every session's context.
|
||||||
|
- **The daemon runs from `fleetd/run/fleetd.jar`, not `fleetd/target/fleetd.jar`** (fleetd #664).
|
||||||
|
Maven's own output still lands at `fleetd/target/fleetd.jar` — that part of the build is
|
||||||
|
unchanged — but the running daemon never has that file open, so a bare `mvn install`/`mvn clean`
|
||||||
|
in the main clone no longer corrupts anything a live process is reading. Verify merges in a
|
||||||
|
throwaway git worktree anyway: a build in the main clone still ships nothing until
|
||||||
|
`scripts/redeploy-fleetd.sh` moves it into place with its own atomic `mv`, performed only after
|
||||||
|
the old daemon is confirmed gone. Let only that script touch `fleetd/run/fleetd.jar`.
|
||||||
|
|
||||||
### Redeploying the daemon — the lead may do this (primary only)
|
### Redeploying the daemon — the lead may do this (primary only)
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
<string>/Users/dai.ha/LTMS/claude-bridge/scripts/fleetd-launchd-wrapper.sh</string>
|
<string>/Users/dai.ha/LTMS/claude-bridge/scripts/fleetd-launchd-wrapper.sh</string>
|
||||||
<string>/Users/dai.ha/Softwares/jdks/jdk-25.0.3.jdk/Contents/Home/bin/java</string>
|
<string>/Users/dai.ha/Softwares/jdks/jdk-25.0.3.jdk/Contents/Home/bin/java</string>
|
||||||
<string>-jar</string>
|
<string>-jar</string>
|
||||||
<string>/Users/dai.ha/LTMS/claude-bridge/fleetd/target/fleetd.jar</string>
|
<string>/Users/dai.ha/LTMS/claude-bridge/fleetd/run/fleetd.jar</string>
|
||||||
<string>fleetd.yaml</string>
|
<string>fleetd.yaml</string>
|
||||||
</array>
|
</array>
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ WorkingDirectory=%h/LTMS/fleetd/fleetd
|
|||||||
# and looks healthy, and the failure appears hours later as a member that cannot open a pull
|
# and looks healthy, and the failure appears hours later as a member that cannot open a pull
|
||||||
# request. exec keeps it one process, so systemd tracks the right PID.
|
# request. exec keeps it one process, so systemd tracks the right PID.
|
||||||
# This also avoids a SECOND copy of the secrets in a systemd drop-in: one source of truth.
|
# This also avoids a SECOND copy of the secrets in a systemd drop-in: one source of truth.
|
||||||
ExecStart=/bin/zsh -lc "exec java -jar target/fleetd.jar fleetd.yaml"
|
ExecStart=/bin/zsh -lc "exec java -jar run/fleetd.jar fleetd.yaml"
|
||||||
|
|
||||||
# PrivateTmp MUST stay false -- see herdr.service. fleetd creates the member ZDOTDIR scrub dir and
|
# PrivateTmp MUST stay false -- see herdr.service. fleetd creates the member ZDOTDIR scrub dir and
|
||||||
# the opencode config dir under java.io.tmpdir, and the member pane (a herdr child, a different
|
# the opencode config dir under java.io.tmpdir, and the member pane (a herdr child, a different
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
target/
|
target/
|
||||||
dependency-reduced-pom.xml
|
dependency-reduced-pom.xml
|
||||||
|
|
||||||
|
# The daemon's runtime jar (fleetd #664). scripts/redeploy-fleetd.sh moves the built jar here
|
||||||
|
# with a same-filesystem rename; this is never Maven's output path and never belongs in git.
|
||||||
|
run/
|
||||||
|
|
||||||
# Local runtime config (copy from fleetd.example.yaml). Both names are ignored: fleetd.yaml is
|
# Local runtime config (copy from fleetd.example.yaml). Both names are ignored: fleetd.yaml is
|
||||||
# the current name, and bridged.yaml is the legacy name Fleetd still falls back to.
|
# the current name, and bridged.yaml is the legacy name Fleetd still falls back to.
|
||||||
fleetd.yaml
|
fleetd.yaml
|
||||||
|
|||||||
@@ -669,6 +669,15 @@ fleet:
|
|||||||
# kind: opencode
|
# kind: opencode
|
||||||
# model: openai/gpt-5.6-terra
|
# model: openai/gpt-5.6-terra
|
||||||
|
|
||||||
|
# A collaborator tab, keyed by name (fleetd #669). This block is parsed and validated today;
|
||||||
|
# nothing yet recognises or addresses the tab it names. Recognise-only, like a profile-less
|
||||||
|
# `leaders:` entry above: there is no `profile:`, no `instances:` and no `kind:`. `tab:` is
|
||||||
|
# REQUIRED and is the only field identity depends on, matched case-insensitively — the same
|
||||||
|
# GET-THE-VALUE-RIGHT warning above the `leaders:` block applies here too.
|
||||||
|
# collaborators:
|
||||||
|
# reviewer-alex:
|
||||||
|
# tab: "collab: alex"
|
||||||
|
|
||||||
# architects:
|
# architects:
|
||||||
# architect-1:
|
# architect-1:
|
||||||
# profile: opus # a strong model, on the operator's subscription
|
# profile: opus # a strong model, on the operator's subscription
|
||||||
|
|||||||
+4
-2
@@ -189,8 +189,10 @@
|
|||||||
|
|
||||||
<build>
|
<build>
|
||||||
<!-- CB-634: the cutover renamed the module dir (bridged/ -> fleetd/), the jar, and the
|
<!-- CB-634: the cutover renamed the module dir (bridged/ -> fleetd/), the jar, and the
|
||||||
launchd plist together. The installed plist names fleetd/target/fleetd.jar and
|
launchd plist together. fleetd #664: the installed plist and the systemd unit now name
|
||||||
KeepAlive is armed, so this name, the plist, and the wrapper must move as one. -->
|
fleetd/run/fleetd.jar, not this plugin's own output path — see
|
||||||
|
scripts/redeploy-fleetd.sh for the mv that gets a build from here to there. KeepAlive is
|
||||||
|
armed, so this name, the plist, and the wrapper must still move as one. -->
|
||||||
<finalName>fleetd</finalName>
|
<finalName>fleetd</finalName>
|
||||||
<plugins>
|
<plugins>
|
||||||
<plugin>
|
<plugin>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package dev.ltms.fleet.auth;
|
package dev.ltms.fleet.auth;
|
||||||
|
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The authorization table (CB-505), stated once and enforced on both entry paths.
|
* The authorization table (CB-505), stated once and enforced on both entry paths.
|
||||||
*
|
*
|
||||||
@@ -20,16 +22,22 @@ public final class Authz {
|
|||||||
SPAWN,
|
SPAWN,
|
||||||
/** Tear a worker peer down. */
|
/** Tear a worker peer down. */
|
||||||
STOP,
|
STOP,
|
||||||
/** Deliver a turn to a session (or answer a worker's question). */
|
/** Deliver a turn to a local session, addressed by {@code sessionId}. */
|
||||||
SEND,
|
SEND,
|
||||||
|
/** Resolve a worker's blocked question and resume its turn, addressed by {@code turnId}. */
|
||||||
|
ANSWER,
|
||||||
|
/** Address a peer lead on another daemon over the coordination broker, by {@code coordId}. */
|
||||||
|
COORD_SEND,
|
||||||
/** A worker's terminal reply for its own turn. */
|
/** A worker's terminal reply for its own turn. */
|
||||||
REPLY,
|
REPLY,
|
||||||
/** A worker's mid-turn question to the primary. */
|
/** A worker's mid-turn question to the primary. */
|
||||||
ASK,
|
ASK,
|
||||||
/** Collect held replies from a session's inbox. */
|
/** Collect held replies from a session's inbox. */
|
||||||
DRAIN,
|
DRAIN,
|
||||||
/** Read-only observation: status, roster, profiles, task polling. */
|
/** Read-only roster, profile, and identity observation: no ticket, task, or turn state. */
|
||||||
READ,
|
READ,
|
||||||
|
/** Poll a ticket, or read a session's status. */
|
||||||
|
TASK_READ,
|
||||||
/**
|
/**
|
||||||
* Read (never ack) this daemon's own held lead-to-lead coordination mail (fleetd #421).
|
* Read (never ack) this daemon's own held lead-to-lead coordination mail (fleetd #421).
|
||||||
*
|
*
|
||||||
@@ -54,43 +62,92 @@ public final class Authz {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether {@code caller} may perform {@code action} against {@code targetSession}.
|
* Stands in for the terminal-to-tab registry a collaborator's {@code SEND} is checked
|
||||||
*
|
* against, until one exists: answers no for every target, so a collaborator reaches nothing
|
||||||
* @param targetSession the session id in the request path; only consulted for the worker-scoped
|
* today. Both production gates ({@code FleetMcp#denyFor}, {@code FleetApp#allow}) pass this
|
||||||
* actions ({@code REPLY}, {@code ASK}), ignored otherwise, may be
|
* exact instance, so the classifier is defined once and replacing it is a one-line change in
|
||||||
* {@code null}
|
* each.
|
||||||
|
*/
|
||||||
|
public static final Predicate<String> NO_KNOWN_LEAD_OR_COLLABORATOR = target -> false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience form for a caller with no classifier to supply. Fails closed: a collaborator's
|
||||||
|
* {@code SEND} is refused, as if no terminal were a configured lead or collaborator — the
|
||||||
|
* same decision {@link #NO_KNOWN_LEAD_OR_COLLABORATOR} gives explicitly. Every other action's
|
||||||
|
* result is identical to the four-argument form's, since none of them consult the classifier.
|
||||||
*/
|
*/
|
||||||
public static boolean permits(Principal caller, Action action, String targetSession) {
|
public static boolean permits(Principal caller, Action action, String targetSession) {
|
||||||
|
return permits(caller, action, targetSession, NO_KNOWN_LEAD_OR_COLLABORATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether {@code caller} may perform {@code action} against {@code targetSession}.
|
||||||
|
*
|
||||||
|
* @param targetSession the session id in the request path; only consulted for the
|
||||||
|
* worker-scoped actions ({@code REPLY}, {@code ASK}) and for a
|
||||||
|
* collaborator's {@code SEND}, ignored otherwise, may be
|
||||||
|
* {@code null}
|
||||||
|
* @param knownLeadOrCollaborator whether a terminal is a configured lead or collaborator —
|
||||||
|
* consulted only for a collaborator's {@code SEND}, to confine
|
||||||
|
* it to another named peer and never a spawned member's
|
||||||
|
* terminal
|
||||||
|
*/
|
||||||
|
public static boolean permits(Principal caller, Action action, String targetSession,
|
||||||
|
Predicate<String> knownLeadOrCollaborator) {
|
||||||
if (caller == null || caller.isAnonymous()) {
|
if (caller == null || caller.isAnonymous()) {
|
||||||
return false; // authenticated as nothing ⇒ authorized for nothing
|
return false; // authenticated as nothing ⇒ authorized for nothing
|
||||||
}
|
}
|
||||||
return switch (action) {
|
return switch (action) {
|
||||||
// Fleet lifecycle is the primary's alone — spawn, stop, drain. An architect
|
// Fleet lifecycle is the primary's alone — spawn, stop, drain. An architect and a
|
||||||
// deliberately does NOT get these (CB-548), so it cannot tear down or stand up workers
|
// collaborator deliberately do NOT get these, so neither can tear down or stand up
|
||||||
// even though it coordinates them; and a worker driving any of these would be a worker
|
// workers even though one of them coordinates them; and a worker driving any of these
|
||||||
// escalating into the orchestrator role.
|
// would be a worker escalating into the orchestrator role.
|
||||||
case SPAWN, STOP, DRAIN, HANDOVER -> caller.isPrimary();
|
case SPAWN, STOP, DRAIN, HANDOVER -> caller.isPrimary();
|
||||||
|
|
||||||
// Delivering a turn is open to the primary and the architect: an architect delegates
|
// Delivering a turn to a local session is open to the primary, the architect, and a
|
||||||
// to workers (that is the role's point) but still has no lifecycle rights. A worker is
|
// collaborator whose target is itself a configured lead or collaborator: the architect
|
||||||
// excluded — sending would be it escalating.
|
// delegates to workers (that is the role's point); a collaborator may reach only
|
||||||
case SEND -> caller.isPrimary() || caller.isArchitect();
|
// another named peer, never a spawned member's terminal. A worker is excluded —
|
||||||
|
// sending would be it escalating.
|
||||||
|
case SEND -> caller.isPrimary() || caller.isArchitect()
|
||||||
|
|| (caller.isCollaborator() && knownLeadOrCollaborator.test(targetSession));
|
||||||
|
|
||||||
|
// Resolving a worker's blocked question is part of delegating to it, open to the same
|
||||||
|
// two roles that may stand up that delegation in the first place. Not a collaborator:
|
||||||
|
// resuming another session's turn is lifecycle-adjacent, not peer messaging.
|
||||||
|
case ANSWER -> caller.isPrimary() || caller.isArchitect();
|
||||||
|
|
||||||
|
// Leaves the daemon over the coordination broker rather than addressing a local
|
||||||
|
// session, open to the same two roles as ANSWER. Not a collaborator: it is a
|
||||||
|
// local-tab peer with no cross-host route.
|
||||||
|
case COORD_SEND -> caller.isPrimary() || caller.isArchitect();
|
||||||
|
|
||||||
// The load-bearing rule: a caller acts only as the pane it occupies. CB-532 widened who
|
// The load-bearing rule: a caller acts only as the pane it occupies. CB-532 widened who
|
||||||
// that can be — a lead answering another lead is replying for its OWN terminal, which
|
// that can be — a lead answering another lead is replying for its OWN terminal, which
|
||||||
// this already permits — while the rule itself is unchanged, and is what stops anyone
|
// this already permits — while the rule itself is unchanged, and is what stops anyone
|
||||||
// forging a reply for a rendezvous someone else is waiting on. An architect's own pane
|
// forging a reply for a rendezvous someone else is waiting on. An architect's or a
|
||||||
// passes through the same check, so it can answer a funnel that delegated to it. An
|
// collaborator's own pane passes through the same check, so each can answer a funnel
|
||||||
// unnamed primary (token/loopback, no pane) owns nothing and is still excluded.
|
// that delegated to it. An unnamed primary (token/loopback, no pane) owns nothing and
|
||||||
|
// is still excluded.
|
||||||
case REPLY, ASK -> caller.ownsSession(targetSession);
|
case REPLY, ASK -> caller.ownsSession(targetSession);
|
||||||
|
|
||||||
// Observation is open to every authenticated role: a worker legitimately polls its own
|
// READ is roster, profile, and identity observation — fleet_list, fleet_profiles, and
|
||||||
// status, and the roster carries no secrets.
|
// fleet_whoami — and carries no secrets: no ticket reply, no pending question, and no
|
||||||
case READ, METRICS -> caller.isPrimary() || caller.isWorker() || caller.isArchitect();
|
// other session's turn state. Those live under TASK_READ. METRICS is the separate
|
||||||
|
// Prometheus scrape. Both are open to every authenticated role, including a
|
||||||
|
// collaborator.
|
||||||
|
case READ, METRICS -> caller.isPrimary() || caller.isWorker() || caller.isArchitect()
|
||||||
|
|| caller.isCollaborator();
|
||||||
|
|
||||||
|
// Ticket polling and session status, open to every role READ is open to except a
|
||||||
|
// collaborator: ticket ids are a sequential counter with no owner check, so a holder
|
||||||
|
// could walk every ticket and read another session's delegation reply.
|
||||||
|
case TASK_READ -> caller.isPrimary() || caller.isWorker() || caller.isArchitect();
|
||||||
|
|
||||||
// fleetd #421: reading held lead-to-lead mail is the primary's alone. An architect
|
// fleetd #421: reading held lead-to-lead mail is the primary's alone. An architect
|
||||||
// holds READ today (CB-548), so "not primary" must mean not-architect here too — this
|
// holds READ today (CB-548), so "not primary" must mean not-architect here too — this
|
||||||
// is coordination between leads, not observation of the roster.
|
// is coordination between leads, not observation of the roster. The same reasoning
|
||||||
|
// excludes a collaborator.
|
||||||
case COORD_READ -> caller.isPrimary();
|
case COORD_READ -> caller.isPrimary();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ package dev.ltms.fleet.auth;
|
|||||||
* @param pid the connecting process id, or {@code -1} when not resolvable (audit context)
|
* @param pid the connecting process id, or {@code -1} when not resolvable (audit context)
|
||||||
* @param name for a lead resolved from the CB-530 {@code leaders:} registry, which lead it is;
|
* @param name for a lead resolved from the CB-530 {@code leaders:} registry, which lead it is;
|
||||||
* for an architect resolved from the CB-548 {@code architects:} registry, which
|
* for an architect resolved from the CB-548 {@code architects:} registry, which
|
||||||
* slot it occupies; {@code null} for every other caller, including an unnamed primary
|
* slot it occupies; for a collaborator resolved from the {@code collaborators:}
|
||||||
|
* registry, which collaborator it is; {@code null} for every other caller,
|
||||||
|
* including an unnamed primary
|
||||||
*/
|
*/
|
||||||
public record Principal(Role role, String terminal, long pid, String name) {
|
public record Principal(Role role, String terminal, long pid, String name) {
|
||||||
|
|
||||||
@@ -73,6 +75,19 @@ public record Principal(Role role, String terminal, long pid, String name) {
|
|||||||
return new Principal(Role.ARCHITECT, terminal, pid, slotName);
|
return new Principal(Role.ARCHITECT, terminal, pid, slotName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collaborator: a human-opened tab recognised by its exact label in the
|
||||||
|
* {@code collaborators:} registry.
|
||||||
|
*
|
||||||
|
* <p>Carries {@link Role#COLLABORATOR}. {@code name} is reporting only — it lets
|
||||||
|
* {@code fleet_whoami} say which collaborator is asking. Identity is the {@code terminal}:
|
||||||
|
* like a worker's it comes from the connection, so {@code ownsSession} works exactly as it
|
||||||
|
* does for a worker — a collaborator acts as its own pane and no other.
|
||||||
|
*/
|
||||||
|
public static Principal collaborator(String name, String terminal, long pid) {
|
||||||
|
return new Principal(Role.COLLABORATOR, terminal, pid, name);
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isPrimary() {
|
public boolean isPrimary() {
|
||||||
return role == Role.PRIMARY;
|
return role == Role.PRIMARY;
|
||||||
}
|
}
|
||||||
@@ -81,6 +96,10 @@ public record Principal(Role role, String terminal, long pid, String name) {
|
|||||||
return role == Role.ARCHITECT;
|
return role == Role.ARCHITECT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isCollaborator() {
|
||||||
|
return role == Role.COLLABORATOR;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isWorker() {
|
public boolean isWorker() {
|
||||||
return role == Role.WORKER;
|
return role == Role.WORKER;
|
||||||
}
|
}
|
||||||
@@ -120,6 +139,7 @@ public record Principal(Role role, String terminal, long pid, String name) {
|
|||||||
return switch (role) {
|
return switch (role) {
|
||||||
case WORKER -> "worker:" + terminal;
|
case WORKER -> "worker:" + terminal;
|
||||||
case ARCHITECT -> "architect:" + name;
|
case ARCHITECT -> "architect:" + name;
|
||||||
|
case COLLABORATOR -> "collaborator:" + name;
|
||||||
case PRIMARY -> name == null ? "primary" : "leader:" + name;
|
case PRIMARY -> name == null ? "primary" : "leader:" + name;
|
||||||
case ANONYMOUS -> "anonymous";
|
case ANONYMOUS -> "anonymous";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,17 @@ public enum Role {
|
|||||||
*/
|
*/
|
||||||
ARCHITECT,
|
ARCHITECT,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A config-declared, human-opened tab recognised by its exact label (the {@code
|
||||||
|
* fleet.collaborators.<name>.tab} registry). Never spawned — identity comes from the
|
||||||
|
* connection, never a request argument, exactly like {@link #WORKER} and {@link #ARCHITECT}.
|
||||||
|
* May {@code SEND} only to a configured lead or collaborator, {@code REPLY}/{@code ASK} only
|
||||||
|
* as its own pane, and {@code READ}/{@code METRICS}; may not {@code SPAWN}/{@code STOP}/
|
||||||
|
* {@code DRAIN}/{@code HANDOVER}, poll a ticket ({@code TASK_READ}), or reach the
|
||||||
|
* coordination broker ({@code COORD_SEND}/{@code COORD_READ}).
|
||||||
|
*/
|
||||||
|
COLLABORATOR,
|
||||||
|
|
||||||
/** Authenticated as nothing. Authorized for nothing but {@code /healthz}. */
|
/** Authenticated as nothing. Authorized for nothing but {@code /healthz}. */
|
||||||
ANONYMOUS
|
ANONYMOUS
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1129,11 +1129,8 @@ public record FleetConfig(
|
|||||||
* {@code tab} can never be discovered, launched or not
|
* {@code tab} can never be discovered, launched or not
|
||||||
* @param instances how many of this lead should be live (default 1). The daemon
|
* @param instances how many of this lead should be live (default 1). The daemon
|
||||||
* launches only the shortfall, so a restart adopts rather than doubles
|
* launches only the shortfall, so a restart adopts rather than doubles
|
||||||
* @param tabPrefix no longer used to find a lead's tab — {@code tab} is matched
|
* @param tabPrefix lead-tab naming convention checked against member labels. Lead
|
||||||
* exactly. Its only remaining job is the startup collision guard
|
* identity uses {@code tab}. Default {@code "lead:"}
|
||||||
* ({@link #validateLeadTabPrefixes()}), which still uses it to refuse
|
|
||||||
* a worker {@code tabLabel} template that could be misread as a lead.
|
|
||||||
* Default {@code "lead:"}
|
|
||||||
* @param scanIntervalSeconds how long a tab scan is cached before herdr is asked again; also the
|
* @param scanIntervalSeconds how long a tab scan is cached before herdr is asked again; also the
|
||||||
* worst case before a newly-labelled tab is recognised. Default 10
|
* worst case before a newly-labelled tab is recognised. Default 10
|
||||||
* @param kind which agent runs there ({@code claude}, {@code opencode}, …)
|
* @param kind which agent runs there ({@code claude}, {@code opencode}, …)
|
||||||
@@ -1179,6 +1176,25 @@ public record FleetConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tab fleetd recognises as a collaborator, keyed by name (fleetd #669).
|
||||||
|
*
|
||||||
|
* <p>Recognise-only: there is no {@code profile}, no {@code instances} and no {@code kind}.
|
||||||
|
* Nothing here ever launches a pane.
|
||||||
|
*
|
||||||
|
* <p>{@code tabPrefix} is absent. Identity is matched on the exact {@code tab} alone.
|
||||||
|
*
|
||||||
|
* @param tab the exact tab label hosting this collaborator, matched case-insensitively; the
|
||||||
|
* only field identity depends on. Required — an entry with no {@code tab} can
|
||||||
|
* never be discovered.
|
||||||
|
*/
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Collaborator(String tab) {
|
||||||
|
public Collaborator {
|
||||||
|
tab = (tab == null || tab.isBlank()) ? null : tab.strip();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One entry of a {@code fleet:} role pool — a role paired with the backend it runs on.
|
* One entry of a {@code fleet:} role pool — a role paired with the backend it runs on.
|
||||||
*
|
*
|
||||||
@@ -1216,15 +1232,18 @@ public record FleetConfig(
|
|||||||
* is exactly compatible with that. The pool is also what replaced {@code defaultProfile:} — an
|
* is exactly compatible with that. The pool is also what replaced {@code defaultProfile:} — an
|
||||||
* unqualified spawn names a role, and the role's pool supplies the candidates.
|
* unqualified spawn names a role, and the role's pool supplies the candidates.
|
||||||
*
|
*
|
||||||
* @param leaders panes that orchestrate rather than are orchestrated, keyed by lead name
|
* @param leaders panes that orchestrate rather than are orchestrated, keyed by lead name
|
||||||
* @param architects profiles the {@code architect} role may run on
|
* @param architects profiles the {@code architect} role may run on
|
||||||
* @param developers profiles the {@code dev} role may run on
|
* @param developers profiles the {@code dev} role may run on
|
||||||
* @param hunters profiles the {@code hunter} role may run on
|
* @param hunters profiles the {@code hunter} role may run on
|
||||||
* @param reviewers profiles the {@code reviewer} role may run on
|
* @param reviewers profiles the {@code reviewer} role may run on
|
||||||
* @param charters optional launch-charter text keyed by singular role wire name
|
* @param charters optional launch-charter text keyed by singular role wire name
|
||||||
* @param tabLabel template for a member tab's label; {@code {role}}, {@code {profile}},
|
* @param tabLabel template for a member tab's label; {@code {role}}, {@code {profile}},
|
||||||
* {@code {model}} and {@code {n}} (a per role+profile counter) are
|
* {@code {model}} and {@code {n}} (a per role+profile counter) are
|
||||||
* substituted. Default {@link #DEFAULT_TAB_LABEL}
|
* substituted. Default {@link #DEFAULT_TAB_LABEL}
|
||||||
|
* @param collaborators tabs fleetd recognises as collaborators (fleetd #669), keyed by name.
|
||||||
|
* Recognise-only, exactly like a {@code profile}-less {@link Leader}:
|
||||||
|
* nothing here is ever auto-launched.
|
||||||
*/
|
*/
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public record Fleet(Map<String, Leader> leaders,
|
public record Fleet(Map<String, Leader> leaders,
|
||||||
@@ -1233,13 +1252,11 @@ public record FleetConfig(
|
|||||||
Map<String, Slot> hunters,
|
Map<String, Slot> hunters,
|
||||||
Map<String, Slot> reviewers,
|
Map<String, Slot> reviewers,
|
||||||
Map<String, String> charters,
|
Map<String, String> charters,
|
||||||
String tabLabel) {
|
String tabLabel,
|
||||||
|
Map<String, Collaborator> collaborators) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Role first, so the tab bar reads as the fleet and so the label shares a namespace with a
|
* Role first, so the tab bar identifies the member's fleet role.
|
||||||
* lead's {@code tabPrefix}. Because {@code {role}} comes from a closed enum, a generated
|
|
||||||
* member label can never begin with {@code "lead:"} — the clash that
|
|
||||||
* {@link #validateLeadTabPrefixes()} used to have to check for is unrepresentable here.
|
|
||||||
*/
|
*/
|
||||||
public static final String DEFAULT_TAB_LABEL = "{role}: {profile} #{n}";
|
public static final String DEFAULT_TAB_LABEL = "{role}: {profile} #{n}";
|
||||||
|
|
||||||
@@ -1251,26 +1268,30 @@ public record FleetConfig(
|
|||||||
reviewers = unmodifiableOrEmpty(reviewers);
|
reviewers = unmodifiableOrEmpty(reviewers);
|
||||||
charters = unmodifiableOrEmpty(charters);
|
charters = unmodifiableOrEmpty(charters);
|
||||||
tabLabel = (tabLabel == null || tabLabel.isBlank()) ? DEFAULT_TAB_LABEL : tabLabel;
|
tabLabel = (tabLabel == null || tabLabel.isBlank()) ? DEFAULT_TAB_LABEL : tabLabel;
|
||||||
|
collaborators = unmodifiableOrEmpty(collaborators);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A fleet with no configured launch charters — the shape every deployment had before
|
* A fleet with no configured launch charters and no collaborators — the shape every
|
||||||
* CB-566, and what most tests want.
|
* deployment had before CB-566, and what most tests want.
|
||||||
*
|
*
|
||||||
* <p>Kept deliberately, even though an overload that drops a new field is normally the
|
* <p>Kept deliberately, even though an overload that drops a new field is normally the
|
||||||
* shape to avoid. It is safe here because nothing <em>reads</em> a charter through a
|
* shape to avoid. It is safe here because nothing <em>reads</em> a charter through a
|
||||||
* constructor: the launcher reads {@code fleet.charters()} from the live config. Jackson
|
* constructor: the launcher reads {@code fleet.charters()} from the live config. Jackson
|
||||||
* binds the canonical constructor, so this one cannot swallow an operator's YAML.
|
* binds the canonical constructor, so this one cannot swallow an operator's YAML.
|
||||||
|
* {@code collaborators} is dropped the same way and for the same reason: no caller of
|
||||||
|
* this overload has ever needed to set it, so it defaults to empty here exactly as the
|
||||||
|
* canonical constructor would default an absent YAML key.
|
||||||
*/
|
*/
|
||||||
public Fleet(Map<String, Leader> leaders, Map<String, Slot> architects,
|
public Fleet(Map<String, Leader> leaders, Map<String, Slot> architects,
|
||||||
Map<String, Slot> developers, Map<String, Slot> reviewers,
|
Map<String, Slot> developers, Map<String, Slot> reviewers,
|
||||||
Map<String, String> charters, String tabLabel) {
|
Map<String, String> charters, String tabLabel) {
|
||||||
this(leaders, architects, developers, null, reviewers, charters, tabLabel);
|
this(leaders, architects, developers, null, reviewers, charters, tabLabel, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Fleet(Map<String, Leader> leaders, Map<String, Slot> architects,
|
public Fleet(Map<String, Leader> leaders, Map<String, Slot> architects,
|
||||||
Map<String, Slot> developers, Map<String, Slot> reviewers, String tabLabel) {
|
Map<String, Slot> developers, Map<String, Slot> reviewers, String tabLabel) {
|
||||||
this(leaders, architects, developers, null, reviewers, null, tabLabel);
|
this(leaders, architects, developers, null, reviewers, null, tabLabel, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1399,11 +1420,13 @@ public record FleetConfig(
|
|||||||
* called FROM the calling lead's own turn, so its pane is still {@code WORKING} the instant
|
* called FROM the calling lead's own turn, so its pane is still {@code WORKING} the instant
|
||||||
* {@code confirm()} validates every gate and schedules the roll. {@code
|
* {@code confirm()} validates every gate and schedules the roll. {@code
|
||||||
* dev.ltms.fleet.lead.LeadRollover}'s deferred continuation waits up to this many seconds for
|
* dev.ltms.fleet.lead.LeadRollover}'s deferred continuation waits up to this many seconds for
|
||||||
* that SAME pane to report an injectable state again — i.e. for the calling turn to actually
|
* that SAME pane to report {@code IDLE} or {@code DONE} — i.e. for the calling turn to actually
|
||||||
* end — before it sends {@code /clear} at all. If that wait times out, no {@code /clear} is
|
* end — before it sends {@code /clear} at all. {@code BLOCKED} does not count: that is a live
|
||||||
|
* turn merely paused, not one that has finished. If that wait times out, no {@code /clear} is
|
||||||
* ever sent: a lead that never goes idle is still doing real work, and clearing it would
|
* ever sent: a lead that never goes idle is still doing real work, and clearing it would
|
||||||
* destroy live context. This is a separate wait from {@code clearSettleSeconds} below, which
|
* destroy live context. This is a separate wait from {@code clearSettleSeconds} below, which
|
||||||
* bounds the SECOND wait, for the pane to re-settle AFTER {@code /clear} has already gone out.
|
* bounds the SECOND wait, for the pane to reach {@code IDLE} or {@code DONE} again AFTER
|
||||||
|
* {@code /clear} has already gone out.
|
||||||
*
|
*
|
||||||
* @param handoverPath required when this block is present — where the handover file a fresh
|
* @param handoverPath required when this block is present — where the handover file a fresh
|
||||||
* lead session reads must live. There is no sane non-null default for an
|
* lead session reads must live. There is no sane non-null default for an
|
||||||
@@ -1422,12 +1445,13 @@ public record FleetConfig(
|
|||||||
* @param maxDocAgeSeconds default 3600 — refuse a handover file whose modified time is older
|
* @param maxDocAgeSeconds default 3600 — refuse a handover file whose modified time is older
|
||||||
* than this many seconds, so a stale leftover from an earlier rollover
|
* than this many seconds, so a stale leftover from an earlier rollover
|
||||||
* attempt can never be mistaken for a fresh one.
|
* attempt can never be mistaken for a fresh one.
|
||||||
* @param turnSettleSeconds default 20 — bound on how long the deferred roll waits for the
|
* @param turnSettleSeconds default 300 — bound on how long the deferred roll waits for the
|
||||||
* CALLING lead's own turn to end (its pane to report injectable again)
|
* CALLING lead's own turn to end (its pane to report {@code IDLE} or
|
||||||
* before sending {@code /clear} at all. See the paragraph above.
|
* {@code DONE}) before sending {@code /clear} at all. See the paragraph
|
||||||
|
* above.
|
||||||
* @param clearSettleSeconds default 20 — bound on how long to wait for the lead's pane to
|
* @param clearSettleSeconds default 20 — bound on how long to wait for the lead's pane to
|
||||||
* report an injectable state again after {@code /clear} before giving up. A
|
* report {@code IDLE} or {@code DONE} again after {@code /clear} before
|
||||||
* roll that times out here never sends {@code bootstrapText}.
|
* giving up. A roll that times out here never sends {@code bootstrapText}.
|
||||||
* @param bootstrapText default a sentence naming the RESOLVED handover path — sent to the
|
* @param bootstrapText default a sentence naming the RESOLVED handover path — sent to the
|
||||||
* lead's pane once it settles after {@code /clear}, telling the fresh
|
* lead's pane once it settles after {@code /clear}, telling the fresh
|
||||||
* session where to read the handover and carry on. Left {@code null} here
|
* session where to read the handover and carry on. Left {@code null} here
|
||||||
@@ -1444,7 +1468,7 @@ public record FleetConfig(
|
|||||||
public LeadRollover {
|
public LeadRollover {
|
||||||
requireOperatorConfirm = requireOperatorConfirm == null || requireOperatorConfirm;
|
requireOperatorConfirm = requireOperatorConfirm == null || requireOperatorConfirm;
|
||||||
maxDocAgeSeconds = (maxDocAgeSeconds == null || maxDocAgeSeconds <= 0) ? 3600 : maxDocAgeSeconds;
|
maxDocAgeSeconds = (maxDocAgeSeconds == null || maxDocAgeSeconds <= 0) ? 3600 : maxDocAgeSeconds;
|
||||||
turnSettleSeconds = (turnSettleSeconds == null || turnSettleSeconds <= 0) ? 20 : turnSettleSeconds;
|
turnSettleSeconds = (turnSettleSeconds == null || turnSettleSeconds <= 0) ? 300 : turnSettleSeconds;
|
||||||
clearSettleSeconds = (clearSettleSeconds == null || clearSettleSeconds <= 0) ? 20 : clearSettleSeconds;
|
clearSettleSeconds = (clearSettleSeconds == null || clearSettleSeconds <= 0) ? 20 : clearSettleSeconds;
|
||||||
bootstrapText = (bootstrapText == null || bootstrapText.isBlank()) ? null : bootstrapText;
|
bootstrapText = (bootstrapText == null || bootstrapText.isBlank()) ? null : bootstrapText;
|
||||||
}
|
}
|
||||||
@@ -1913,7 +1937,7 @@ public record FleetConfig(
|
|||||||
|
|
||||||
/** The {@code fleet:} child blocks whose direct children are slot names. */
|
/** The {@code fleet:} child blocks whose direct children are slot names. */
|
||||||
private static final Set<String> FLEET_POOL_KEYS =
|
private static final Set<String> FLEET_POOL_KEYS =
|
||||||
Set.of("leaders", "architects", "developers", "hunters", "reviewers");
|
Set.of("leaders", "architects", "developers", "hunters", "reviewers", "collaborators");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject a {@code fleet:} role pool whose slot names repeat (CB-548, re-homed by CB-557).
|
* Reject a {@code fleet:} role pool whose slot names repeat (CB-548, re-homed by CB-557).
|
||||||
@@ -1923,7 +1947,7 @@ public record FleetConfig(
|
|||||||
* daemon would never know. Jackson's YAML parser does not fail on duplicate mapping keys by
|
* daemon would never know. Jackson's YAML parser does not fail on duplicate mapping keys by
|
||||||
* default, so duplicates are caught here, at parse time, before the map is built.
|
* default, so duplicates are caught here, at parse time, before the map is built.
|
||||||
*
|
*
|
||||||
* <p>Only the five pools <em>directly under the top-level {@code fleet:}</em> are considered,
|
* <p>Only the six pools <em>directly under the top-level {@code fleet:}</em> are considered,
|
||||||
* and only their direct child keys (the slot names). A nested field elsewhere, even one also
|
* and only their direct child keys (the slot names). A nested field elsewhere, even one also
|
||||||
* named {@code developers:}, is ignored, so parsing of the rest of the config is unaffected.
|
* named {@code developers:}, is ignored, so parsing of the rest of the config is unaffected.
|
||||||
*
|
*
|
||||||
@@ -2682,31 +2706,21 @@ public record FleetConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject a lead-scan convention that a worker tab would also satisfy (CB-531).
|
* Reject a member tab-label template that could render as a configured lead or collaborator
|
||||||
|
* tab or match a lead-tab naming convention, and reject two {@code fleet.leaders} or
|
||||||
|
* {@code fleet.collaborators} entries — across either registry — that share one exact tab.
|
||||||
*
|
*
|
||||||
* <p>The scan reads a tab label and concludes "a lead lives here". fleetd also <em>writes</em>
|
* <p>{@code fleet.collaborators} has no {@code tabPrefix}: identity is matched on the exact
|
||||||
* tab labels — every member gets one rendered into its tab. Choose a lead {@code tabPrefix} that
|
* {@code tab} alone, so only the exact-render check applies there, not the prefix check.
|
||||||
* a member template matches and the daemon starts labelling its own members as leads, promoting
|
|
||||||
* the entire fleet to {@link dev.ltms.fleet.auth.Role#PRIMARY} with no message and no diff.
|
|
||||||
* {@link #validatePanePlacementAgainstLeadTabs()} is the check that stops a pane-placed member
|
|
||||||
* from landing inside a lead's tab in the first place; this check is a second, independent
|
|
||||||
* guard that catches the hazard even when every profile places members correctly, by refusing
|
|
||||||
* a label that a scan would still misread as a lead.
|
|
||||||
*
|
*
|
||||||
* <p>CB-557 shrank this check rather than removing it. The default template is
|
* @throws IllegalStateException when the fleet template or a profile {@code tabLabel} override
|
||||||
* {@code "{role}: {profile} #{n}"} and {@code {role}} comes from a closed enum, so a
|
* can render as a configured lead or collaborator tab or match a
|
||||||
* <em>generated</em> label can no longer collide by construction. What remains checkable is what
|
* lead-tab prefix, or when two entries — of either registry, or
|
||||||
* an operator still writes by hand: the {@code fleet.tabLabel} template and any per-profile
|
* one of each — carry the same exact {@code tab}
|
||||||
* {@code tabLabel} override.
|
* (case-insensitively)
|
||||||
*
|
|
||||||
* <p>Fatal rather than a warning, unlike {@link #warnUnknownTopLevelKeys}: an unknown key means
|
|
||||||
* a feature does nothing, while this means a feature does the opposite of what it says.
|
|
||||||
*
|
|
||||||
* @throws IllegalStateException when the fleet template or any profile's {@code tabLabel}
|
|
||||||
* override starts with a configured lead prefix
|
|
||||||
*/
|
*/
|
||||||
public void validateLeadTabPrefixes() {
|
public void validateLeadTabPrefixes() {
|
||||||
if (fleet == null || fleet.leaders().isEmpty()) {
|
if (fleet == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
List<String> bad = new ArrayList<>();
|
List<String> bad = new ArrayList<>();
|
||||||
@@ -2714,51 +2728,173 @@ public record FleetConfig(
|
|||||||
if (leader == null) {
|
if (leader == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
String tab = leader.tab();
|
||||||
String prefix = leader.tabPrefix();
|
String prefix = leader.tabPrefix();
|
||||||
// The fleet-wide template is checked once per prefix: it labels every member that has no
|
if (templateCanRenderAs(fleet.tabLabel(), tab)) {
|
||||||
// override, so one bad template promotes the entire fleet, not one profile.
|
bad.add("fleet.tabLabel=\"" + fleet.tabLabel() + "\" can render as the tab of "
|
||||||
if (startsWithIgnoreCase(fleet.tabLabel(), prefix)) {
|
+ "lead '" + leadName + "' (\"" + tab + "\")");
|
||||||
|
} else if (startsWithIgnoreCase(fleet.tabLabel(), prefix)) {
|
||||||
bad.add("fleet.tabLabel=\"" + fleet.tabLabel() + "\" starts with the tabPrefix of "
|
bad.add("fleet.tabLabel=\"" + fleet.tabLabel() + "\" starts with the tabPrefix of "
|
||||||
+ "lead '" + leadName + "' (\"" + prefix + "\")");
|
+ "lead '" + leadName + "' (\"" + prefix + "\")");
|
||||||
}
|
}
|
||||||
profiles().entrySet().stream()
|
profiles().entrySet().stream()
|
||||||
.filter(e -> startsWithIgnoreCase(e.getValue().tabLabel(), prefix))
|
|
||||||
.map(Map.Entry::getKey)
|
.map(Map.Entry::getKey)
|
||||||
.sorted()
|
.sorted()
|
||||||
.forEach(p -> bad.add("profile '" + p + "' overrides tabLabel with \""
|
.forEach(p -> {
|
||||||
+ profiles().get(p).tabLabel() + "\", which starts with the tabPrefix of "
|
String label = profiles().get(p).tabLabel();
|
||||||
+ "lead '" + leadName + "' (\"" + prefix + "\")"));
|
if (templateCanRenderAs(label, tab)) {
|
||||||
|
bad.add("profile '" + p + "' overrides tabLabel with \"" + label
|
||||||
|
+ "\", which can render as the tab of lead '" + leadName
|
||||||
|
+ "' (\"" + tab + "\")");
|
||||||
|
} else if (startsWithIgnoreCase(label, prefix)) {
|
||||||
|
bad.add("profile '" + p + "' overrides tabLabel with \"" + label
|
||||||
|
+ "\", which starts with the tabPrefix of lead '" + leadName
|
||||||
|
+ "' (\"" + prefix + "\")");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
if (bad.isEmpty()) {
|
fleet.collaborators().forEach((collabName, collaborator) -> {
|
||||||
|
if (collaborator == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String tab = collaborator.tab();
|
||||||
|
if (templateCanRenderAs(fleet.tabLabel(), tab)) {
|
||||||
|
bad.add("fleet.tabLabel=\"" + fleet.tabLabel() + "\" can render as the tab of "
|
||||||
|
+ "collaborator '" + collabName + "' (\"" + tab + "\")");
|
||||||
|
}
|
||||||
|
profiles().entrySet().stream()
|
||||||
|
.map(Map.Entry::getKey)
|
||||||
|
.sorted()
|
||||||
|
.forEach(p -> {
|
||||||
|
String label = profiles().get(p).tabLabel();
|
||||||
|
if (templateCanRenderAs(label, tab)) {
|
||||||
|
bad.add("profile '" + p + "' overrides tabLabel with \"" + label
|
||||||
|
+ "\", which can render as the tab of collaborator '"
|
||||||
|
+ collabName + "' (\"" + tab + "\")");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!bad.isEmpty()) {
|
||||||
|
throw new IllegalStateException("refusing to start: " + String.join("; ", bad)
|
||||||
|
+ ". Every member labelled that way would be read back as a lead or "
|
||||||
|
+ "collaborator and granted that identity's authority. Change one of the two "
|
||||||
|
+ "so member tabs cannot be confused with a lead's or collaborator's tab.");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> collisions = new ArrayList<>();
|
||||||
|
List<String> leadNames = fleet.leaders().keySet().stream().sorted().toList();
|
||||||
|
for (int i = 0; i < leadNames.size(); i++) {
|
||||||
|
String nameA = leadNames.get(i);
|
||||||
|
Leader a = fleet.leaders().get(nameA);
|
||||||
|
if (a == null || a.tab() == null || a.tab().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (int j = i + 1; j < leadNames.size(); j++) {
|
||||||
|
String nameB = leadNames.get(j);
|
||||||
|
Leader b = fleet.leaders().get(nameB);
|
||||||
|
if (b == null || b.tab() == null || b.tab().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (a.tab().equalsIgnoreCase(b.tab())) {
|
||||||
|
collisions.add("lead '" + nameA + "' and lead '" + nameB + "' both use tab \""
|
||||||
|
+ a.tab() + "\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<String> collabNames = fleet.collaborators().keySet().stream().sorted().toList();
|
||||||
|
for (int i = 0; i < collabNames.size(); i++) {
|
||||||
|
String nameA = collabNames.get(i);
|
||||||
|
Collaborator a = fleet.collaborators().get(nameA);
|
||||||
|
if (a == null || a.tab() == null || a.tab().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (int j = i + 1; j < collabNames.size(); j++) {
|
||||||
|
String nameB = collabNames.get(j);
|
||||||
|
Collaborator b = fleet.collaborators().get(nameB);
|
||||||
|
if (b == null || b.tab() == null || b.tab().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (a.tab().equalsIgnoreCase(b.tab())) {
|
||||||
|
collisions.add("collaborator '" + nameA + "' and collaborator '" + nameB
|
||||||
|
+ "' both use tab \"" + a.tab() + "\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String leadName : leadNames) {
|
||||||
|
Leader lead = fleet.leaders().get(leadName);
|
||||||
|
if (lead == null || lead.tab() == null || lead.tab().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (String collabName : collabNames) {
|
||||||
|
Collaborator collaborator = fleet.collaborators().get(collabName);
|
||||||
|
if (collaborator == null || collaborator.tab() == null
|
||||||
|
|| collaborator.tab().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lead.tab().equalsIgnoreCase(collaborator.tab())) {
|
||||||
|
collisions.add("lead '" + leadName + "' and collaborator '" + collabName
|
||||||
|
+ "' both use tab \"" + lead.tab() + "\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (collisions.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new IllegalStateException("refusing to start: " + String.join("; ", bad)
|
throw new IllegalStateException("refusing to start: " + String.join("; ", collisions)
|
||||||
+ ". Every member labelled that way would be read back as a lead and granted "
|
+ ". Tab identity is matched exactly, so only one of two entries sharing a tab can "
|
||||||
+ "spawn/stop/send on the whole fleet. Change one of the two so member tabs and "
|
+ "ever be found — the other is silently unreachable. Give each lead and "
|
||||||
+ "lead tabs cannot be confused.");
|
+ "collaborator its own exact tab.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean templateCanRenderAs(String template, String tab) {
|
||||||
|
if (template == null || template.isBlank() || tab == null || tab.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var placeholders = Pattern.compile("\\{(?:role|profile|model|n)}").matcher(template);
|
||||||
|
StringBuilder expression = new StringBuilder("^");
|
||||||
|
int literalStart = 0;
|
||||||
|
while (placeholders.find()) {
|
||||||
|
expression.append(Pattern.quote(template.substring(literalStart, placeholders.start())));
|
||||||
|
expression.append(".*");
|
||||||
|
literalStart = placeholders.end();
|
||||||
|
}
|
||||||
|
expression.append(Pattern.quote(template.substring(literalStart))).append("$");
|
||||||
|
return Pattern.compile(expression.toString(), Pattern.CASE_INSENSITIVE).matcher(tab).matches();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case-insensitive prefix test that tolerates a null or blank label. */
|
||||||
|
private static boolean startsWithIgnoreCase(String label, String prefix) {
|
||||||
|
if (label == null || prefix == null || prefix.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String stripped = label.strip();
|
||||||
|
return stripped.regionMatches(true, 0, prefix, 0, prefix.length());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject a profile that places its members by {@code "pane"} while any {@code fleet.leaders}
|
* Reject a profile that places its members by {@code "pane"} while any {@code fleet.leaders}
|
||||||
* entry names a {@code tab}. A pane-placed member lands inside the focused tab rather than its
|
* or {@code fleet.collaborators} entry names a {@code tab}. A pane-placed member lands inside
|
||||||
* own, so it can land inside a lead's own labelled tab. {@link
|
* the focused tab rather than its own, so it can land inside a lead's or collaborator's own
|
||||||
* dev.ltms.fleet.herdr.LeadTabScanner} identifies a lead purely by that tab's label — it does
|
* labelled tab. {@link dev.ltms.fleet.herdr.LeadTabScanner} identifies a lead or collaborator
|
||||||
* not exclude the member space — so a member that ends up there would be read back as the lead
|
* purely by that tab's label — it does not exclude the member space — so a member that ends up
|
||||||
* and granted spawn/stop/send on the whole fleet.
|
* there would be read back as that lead or collaborator and granted that identity's authority.
|
||||||
*
|
*
|
||||||
* <p>Only a leader with a non-blank {@code tab} is in scope: one with no {@code tab} feeds
|
* <p>Only an entry with a non-blank {@code tab} is in scope: one with no {@code tab} feeds
|
||||||
* nothing into {@link dev.ltms.fleet.herdr.LeadTabScanner}, so it creates no hazard here.
|
* nothing into {@link dev.ltms.fleet.herdr.LeadTabScanner}, so it creates no hazard here.
|
||||||
*
|
*
|
||||||
* @throws IllegalStateException when any {@code profiles:} entry is pane-placed while any
|
* @throws IllegalStateException when any {@code profiles:} entry is pane-placed while any
|
||||||
* {@code fleet.leaders} entry names a non-blank {@code tab}
|
* {@code fleet.leaders} or {@code fleet.collaborators} entry
|
||||||
|
* names a non-blank {@code tab}
|
||||||
*/
|
*/
|
||||||
public void validatePanePlacementAgainstLeadTabs() {
|
public void validatePanePlacementAgainstLeadTabs() {
|
||||||
if (fleet == null || fleet.leaders().isEmpty()) {
|
if (fleet == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
boolean anyLeaderHasTab = fleet.leaders().values().stream()
|
boolean anyLeaderHasTab = fleet.leaders().values().stream()
|
||||||
.anyMatch(leader -> leader != null && leader.tab() != null && !leader.tab().isBlank());
|
.anyMatch(leader -> leader != null && leader.tab() != null && !leader.tab().isBlank());
|
||||||
if (!anyLeaderHasTab) {
|
boolean anyCollaboratorHasTab = fleet.collaborators().values().stream()
|
||||||
|
.anyMatch(c -> c != null && c.tab() != null && !c.tab().isBlank());
|
||||||
|
if (!anyLeaderHasTab && !anyCollaboratorHasTab) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
List<String> bad = new ArrayList<>();
|
List<String> bad = new ArrayList<>();
|
||||||
@@ -2771,10 +2907,11 @@ public record FleetConfig(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new IllegalStateException("refusing to start: profile(s) " + bad
|
throw new IllegalStateException("refusing to start: profile(s) " + bad
|
||||||
+ " use placement: pane while fleet.leaders names a tab. A pane-placed member can "
|
+ " use placement: pane while fleet.leaders or fleet.collaborators names a tab. A "
|
||||||
+ "land inside a lead's labelled tab and be read back as the lead, granted "
|
+ "pane-placed member can land inside that labelled tab and be read back as the "
|
||||||
+ "spawn/stop/send on the whole fleet. Set placement: tab for each named profile, "
|
+ "lead or collaborator, granted that identity's authority. Set placement: tab for "
|
||||||
+ "or remove the tab from every fleet.leaders entry.");
|
+ "each named profile, or remove the tab from every fleet.leaders and "
|
||||||
|
+ "fleet.collaborators entry.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2797,15 +2934,6 @@ public record FleetConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Case-insensitive prefix test that tolerates a null/blank label. */
|
|
||||||
private static boolean startsWithIgnoreCase(String label, String prefix) {
|
|
||||||
if (label == null || prefix == null || prefix.isBlank()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
String stripped = label.strip();
|
|
||||||
return stripped.regionMatches(true, 0, prefix, 0, prefix.length());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject a subscription profile whose {@code env:} block tries to reseat the Anthropic binding
|
* Reject a subscription profile whose {@code env:} block tries to reseat the Anthropic binding
|
||||||
* (CB-542).
|
* (CB-542).
|
||||||
@@ -2890,8 +3018,14 @@ public record FleetConfig(
|
|||||||
* so duplicates are unrepresentable by construction once loaded — and {@link #load(Path)}
|
* so duplicates are unrepresentable by construction once loaded — and {@link #load(Path)}
|
||||||
* already rejects a duplicated slot name at parse time, before the map collapses.
|
* already rejects a duplicated slot name at parse time, before the map collapses.
|
||||||
*
|
*
|
||||||
* @throws IllegalStateException when a slot names no profile or an unknown one, or when a lead
|
* <p>Also rejects a {@code fleet.collaborators} entry with no (or a blank) {@code tab}. A
|
||||||
* can be neither found nor created, naming the offending entry
|
* {@code profile}-less lead is still useful recognise-only — {@code tab} is the only field
|
||||||
|
* that matters to it either way. A collaborator carries no other field at all, so a blank
|
||||||
|
* {@code tab} leaves nothing for the entry to mean.
|
||||||
|
*
|
||||||
|
* @throws IllegalStateException when a slot names no profile or an unknown one, when a lead
|
||||||
|
* can be neither found nor created, or when a collaborator names
|
||||||
|
* no tab, naming the offending entry
|
||||||
*/
|
*/
|
||||||
public void validateMembers() {
|
public void validateMembers() {
|
||||||
if (fleet == null) {
|
if (fleet == null) {
|
||||||
@@ -2929,6 +3063,16 @@ public record FleetConfig(
|
|||||||
+ "auto-launched, labelled) purely by its tab, so every entry must name one.");
|
+ "auto-launched, labelled) purely by its tab, so every entry must name one.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
fleet.collaborators().forEach((name, collaborator) -> {
|
||||||
|
if (collaborator == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (collaborator.tab() == null || collaborator.tab().isBlank()) {
|
||||||
|
bad.add("fleet.collaborators." + name + " has no tab: — a collaborator is "
|
||||||
|
+ "recognised purely by its tab, and carries no other field, so every "
|
||||||
|
+ "entry must name one.");
|
||||||
|
}
|
||||||
|
});
|
||||||
if (!bad.isEmpty()) {
|
if (!bad.isEmpty()) {
|
||||||
throw new IllegalStateException("refusing to start: " + String.join(" ", bad));
|
throw new IllegalStateException("refusing to start: " + String.join(" ", bad));
|
||||||
}
|
}
|
||||||
@@ -2977,11 +3121,11 @@ public record FleetConfig(
|
|||||||
* Runs every validator this class declares — found by reflection, not by name.
|
* Runs every validator this class declares — found by reflection, not by name.
|
||||||
*
|
*
|
||||||
* <p>fleetd ticket "central allow-list of usable models", follow-up: mutation testing found
|
* <p>fleetd ticket "central allow-list of usable models", follow-up: mutation testing found
|
||||||
* that although each of the six validators above was well pinned on its own, nothing proved
|
* that although each validator above was well pinned on its own, nothing proved
|
||||||
* either real caller ({@code Fleetd.main} and {@link ConfigRef#reload()}) still
|
* either real caller ({@code Fleetd.main} and {@link ConfigRef#reload()}) still
|
||||||
* invoked it — deleting a call site left the full suite green. The fix is not a seventh test
|
* invoked it — deleting a call site left the full suite green. The fix is not one more test
|
||||||
* per caller; a hand-maintained list of six names here would have the exact same defect its
|
* per caller; a hand-maintained list of names here would have the exact same defect its
|
||||||
* own javadoc would warn against: the seventh validator someone adds next month has no reason
|
* own javadoc would warn against: the next validator someone adds has no reason
|
||||||
* to be added to it. So this method does not name any validator. It sweeps {@link
|
* to be added to it. So this method does not name any validator. It sweeps {@link
|
||||||
* #getClass()}'s own public, no-argument, {@code void} methods whose name starts with {@code
|
* #getClass()}'s own public, no-argument, {@code void} methods whose name starts with {@code
|
||||||
* "validate"} (excluding itself) and invokes every one it finds, via {@link
|
* "validate"} (excluding itself) and invokes every one it finds, via {@link
|
||||||
@@ -2990,7 +3134,7 @@ public record FleetConfig(
|
|||||||
* which it silently never runs.
|
* which it silently never runs.
|
||||||
*
|
*
|
||||||
* <p>{@code Fleetd.main} and {@link ConfigRef#reload()} each call this one method instead of
|
* <p>{@code Fleetd.main} and {@link ConfigRef#reload()} each call this one method instead of
|
||||||
* the six individually — see the comments at those two call sites for why
|
* each validator individually — see the comments at those two call sites for why
|
||||||
* each must run it.
|
* each must run it.
|
||||||
*
|
*
|
||||||
* <p>Methods run in a fixed (alphabetical) order, so a config with more than one violation
|
* <p>Methods run in a fixed (alphabetical) order, so a config with more than one violation
|
||||||
@@ -3007,9 +3151,9 @@ public record FleetConfig(
|
|||||||
/**
|
/**
|
||||||
* The reflective sweep behind {@link #validateAll()}, kept as its own method — taking any
|
* The reflective sweep behind {@link #validateAll()}, kept as its own method — taking any
|
||||||
* {@code target}, not just {@code this} — so a test can prove the MECHANISM is generic (it
|
* {@code target}, not just {@code this} — so a test can prove the MECHANISM is generic (it
|
||||||
* would sweep a seventh {@code validateXxx()} method added to any class, not just something
|
* would sweep any new {@code validateXxx()} method added to any class, not just something
|
||||||
* special-cased to today's six on {@link FleetConfig}) without needing to add a real, unwanted
|
* special-cased to the set {@link FleetConfig} declares today) without needing to add a real,
|
||||||
* seventh validator to this class just to exercise that claim. See {@code
|
* unwanted extra validator to this class just to exercise that claim. See {@code
|
||||||
* FleetConfigValidateAllTest} for that proof.
|
* FleetConfigValidateAllTest} for that proof.
|
||||||
*
|
*
|
||||||
* @param target an object whose public, no-argument, {@code void} methods named {@code
|
* @param target an object whose public, no-argument, {@code void} methods named {@code
|
||||||
|
|||||||
@@ -172,12 +172,6 @@ public final class LeadContextGauge {
|
|||||||
* {@code "claude"} (including {@code null}, meaning undetected) reports
|
* {@code "claude"} (including {@code null}, meaning undetected) reports
|
||||||
* {@link State#UNKNOWN} — this reader only understands Claude Code's own
|
* {@link State#UNKNOWN} — this reader only understands Claude Code's own
|
||||||
* transcript format
|
* transcript format
|
||||||
*/
|
|
||||||
public Reading read(String configDir, String sessionId, String agentType) {
|
|
||||||
return read(configDir, sessionId, agentType, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param effectiveWindowTokens the caller's resolved effective auto-compact window for this
|
* @param effectiveWindowTokens the caller's resolved effective auto-compact window for this
|
||||||
* lead's own profile, or {@code null} when it cannot be resolved.
|
* lead's own profile, or {@code null} when it cannot be resolved.
|
||||||
* HIGH fires at {@link #HIGH_THRESHOLD_FRACTION} of this value;
|
* HIGH fires at {@link #HIGH_THRESHOLD_FRACTION} of this value;
|
||||||
|
|||||||
@@ -689,8 +689,8 @@ public final class FleetMcp {
|
|||||||
if (!authorizationEnforced) {
|
if (!authorizationEnforced) {
|
||||||
return null; // AuthorizationMode.UNENFORCED: authorization not enforced (fleetd #518)
|
return null; // AuthorizationMode.UNENFORCED: authorization not enforced (fleetd #518)
|
||||||
}
|
}
|
||||||
if (Authz.permits(caller, action, target)) {
|
if (Authz.permits(caller, action, target, Authz.NO_KNOWN_LEAD_OR_COLLABORATOR)) {
|
||||||
if (action != Authz.Action.READ) {
|
if (action != Authz.Action.READ && action != Authz.Action.TASK_READ) {
|
||||||
AuditLog.allowed(caller, action, target); // reads would drown the trail
|
AuditLog.allowed(caller, action, target); // reads would drown the trail
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -1035,31 +1035,21 @@ public final class FleetMcp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which authorization action a {@code fleet_poll} call needs, decided by its arguments
|
* Which authorization action a {@code fleet_poll} call needs, decided by its arguments.
|
||||||
* (fleetd #272, widened by fleetd #421).
|
|
||||||
*
|
*
|
||||||
* <p>{@code fleet_poll} is now <strong>three operations behind one tool name</strong>. With
|
* <p>{@code fleet_poll} is <strong>three operations behind one tool name</strong>. With
|
||||||
* {@code ticket} it observes an async delegation and changes nothing, which is a {@link
|
* {@code ticket} it observes an async delegation and changes nothing, which is a {@link
|
||||||
* Authz.Action#READ}. With {@code target} it calls {@link MessageService#drainReplies} on that
|
* Authz.Action#TASK_READ}. With {@code target} it calls {@link MessageService#drainReplies} on
|
||||||
* session -- the replies are removed from the inbox and a second call returns nothing -- so it
|
* that session -- the replies are removed from the inbox and a second call returns nothing --
|
||||||
* is a {@link Authz.Action#DRAIN}, the same gate {@code fleet_ack} already uses for removing a
|
* so it is a {@link Authz.Action#DRAIN}, the same gate {@code fleet_ack} already uses for
|
||||||
* single message, and the same one the REST path uses at {@code FleetApp.drainReplies}. With
|
* removing a single message, and the same one the REST path uses at
|
||||||
* {@code coordId} it reads (never acks) this daemon's own held lead-to-lead mail, which is a
|
* {@code FleetApp.drainReplies}. With {@code coordId} it reads (never acks) this daemon's own
|
||||||
* {@link Authz.Action#COORD_READ} -- <strong>not</strong> {@code READ}, even though nothing is
|
* held lead-to-lead mail, which is a {@link Authz.Action#COORD_READ} -- <strong>not</strong>
|
||||||
* consumed: {@code READ}'s grant is open to every authenticated role on the premise that the
|
* {@code TASK_READ} or {@code READ}: a lead-to-lead body is a different inbox from either, and
|
||||||
* roster carries no secrets, and a lead-to-lead body is not the roster. Mapping a non-destructive
|
* folding it into either would let any worker or architect read every peer lead's mail in full.
|
||||||
* peer-mail read to {@code READ} would let any worker read every peer lead's mail in full.
|
|
||||||
*
|
|
||||||
* <p>Before this method existed (fleetd #272) the handler passed a constant {@code READ} for
|
|
||||||
* both of the original branches. {@code READ} is open to every authenticated role, so any
|
|
||||||
* worker could read a peer's id out of {@code fleet_list} and destroy the replies that peer had
|
|
||||||
* queued for the primary. The gate failed open, and it did so because the required action is a
|
|
||||||
* function of the arguments while the handler chose it before looking at them.
|
|
||||||
*
|
*
|
||||||
* <p>The choice lives in this method, and not inline in the handler, so that a test can assert
|
* <p>The choice lives in this method, and not inline in the handler, so that a test can assert
|
||||||
* the mapping the handler actually uses. {@code FleetMcpAuthzTest} already checked every
|
* the mapping the handler actually uses.
|
||||||
* {@link Authz.Action} against every {@link Role} and passed throughout -- it tested the policy
|
|
||||||
* table, which was correct, while the defect was in which action the caller handed it.
|
|
||||||
*
|
*
|
||||||
* <p>Checked first, and exclusively of {@code target}: a call naming {@code coordId} is reading
|
* <p>Checked first, and exclusively of {@code target}: a call naming {@code coordId} is reading
|
||||||
* a different inbox entirely (this daemon's own lead channel, never a worker's), so it takes
|
* a different inbox entirely (this daemon's own lead channel, never a worker's), so it takes
|
||||||
@@ -1072,7 +1062,30 @@ public final class FleetMcp {
|
|||||||
if (!isBlank(coordId)) {
|
if (!isBlank(coordId)) {
|
||||||
return Authz.Action.COORD_READ;
|
return Authz.Action.COORD_READ;
|
||||||
}
|
}
|
||||||
return isBlank(target) ? Authz.Action.READ : Authz.Action.DRAIN;
|
return isBlank(target) ? Authz.Action.TASK_READ : Authz.Action.DRAIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which authorization action a {@code fleet_send} call needs, decided by its arguments.
|
||||||
|
*
|
||||||
|
* <p>{@code fleet_send} is three call shapes behind one tool name, mirroring {@link
|
||||||
|
* #pollAction}. With {@code coordId} it addresses a peer lead on another daemon over the
|
||||||
|
* coordination broker, which is {@link Authz.Action#COORD_SEND}. With {@code turnId} it
|
||||||
|
* resolves a worker's blocked {@code fleet_ask} and resumes that turn, which is {@link
|
||||||
|
* Authz.Action#ANSWER}. Otherwise it delivers to a local session by {@code sessionId}, which is
|
||||||
|
* the plain {@link Authz.Action#SEND}.
|
||||||
|
*
|
||||||
|
* <p>Checked in the same order the handler branches: {@code coordId} first and exclusively of
|
||||||
|
* {@code turnId}, matching {@link #sendToLead}'s own mutual-exclusion check.
|
||||||
|
*
|
||||||
|
* @param coordId the {@code coordId} argument of the call, or {@code null}/blank when absent
|
||||||
|
* @param turnId the {@code turnId} argument of the call, or {@code null}/blank when absent
|
||||||
|
*/
|
||||||
|
static Authz.Action sendAction(String coordId, String turnId) {
|
||||||
|
if (!isBlank(coordId)) {
|
||||||
|
return Authz.Action.COORD_SEND;
|
||||||
|
}
|
||||||
|
return isBlank(turnId) ? Authz.Action.SEND : Authz.Action.ANSWER;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1097,10 +1110,11 @@ public final class FleetMcp {
|
|||||||
*/
|
*/
|
||||||
private static Authz.Action authzAction(FleetTool tool, Map<String, Object> arguments) {
|
private static Authz.Action authzAction(FleetTool tool, Map<String, Object> arguments) {
|
||||||
return switch (tool) {
|
return switch (tool) {
|
||||||
case SEND -> Authz.Action.SEND;
|
case SEND -> sendAction(str(arguments, "coordId"), str(arguments, "turnId"));
|
||||||
case REPLY -> Authz.Action.REPLY;
|
case REPLY -> Authz.Action.REPLY;
|
||||||
case ASK -> Authz.Action.ASK;
|
case ASK -> Authz.Action.ASK;
|
||||||
case STATUS, LIST, PROFILES, WHOAMI -> Authz.Action.READ;
|
case STATUS -> Authz.Action.TASK_READ;
|
||||||
|
case LIST, PROFILES, WHOAMI -> Authz.Action.READ;
|
||||||
case POLL -> pollAction(str(arguments, "target"), str(arguments, "coordId"));
|
case POLL -> pollAction(str(arguments, "target"), str(arguments, "coordId"));
|
||||||
case ACK -> Authz.Action.DRAIN;
|
case ACK -> Authz.Action.DRAIN;
|
||||||
case SPAWN -> Authz.Action.SPAWN;
|
case SPAWN -> Authz.Action.SPAWN;
|
||||||
@@ -1295,6 +1309,18 @@ public final class FleetMcp {
|
|||||||
}
|
}
|
||||||
return text(json(m));
|
return text(json(m));
|
||||||
}
|
}
|
||||||
|
if (caller.isCollaborator()) {
|
||||||
|
// A collaborator's name is its slot in the collaborators: registry; sessionId is its
|
||||||
|
// pane so a peer knows where to reach it. No leader key: a collaborator is not a
|
||||||
|
// primary for authorization, unlike a lead.
|
||||||
|
if (caller.name() != null) {
|
||||||
|
m.put("collaborator", caller.name());
|
||||||
|
}
|
||||||
|
if (caller.terminal() != null) {
|
||||||
|
m.put("sessionId", caller.terminal());
|
||||||
|
}
|
||||||
|
return text(json(m));
|
||||||
|
}
|
||||||
if (!caller.isWorker()) {
|
if (!caller.isWorker()) {
|
||||||
// CB-530: which lead, once more than one pane is configured as one. `role` deliberately
|
// CB-530: which lead, once more than one pane is configured as one. `role` deliberately
|
||||||
// still reads "primary" — the fallback ladder in CLAUDE.md keys on it, and a lead IS a
|
// still reads "primary" — the fallback ladder in CLAUDE.md keys on it, and a lead IS a
|
||||||
|
|||||||
@@ -49,22 +49,52 @@ import java.util.stream.Collectors;
|
|||||||
*/
|
*/
|
||||||
public final class FleetApp {
|
public final class FleetApp {
|
||||||
|
|
||||||
/** The authorization action the matching route handler hands to {@link #allow}. */
|
/**
|
||||||
|
* The authorization action the matching route handler hands to {@link #allow}, for a route
|
||||||
|
* whose action does not depend on the request body.
|
||||||
|
*/
|
||||||
static Authz.Action routeAction(String route) {
|
static Authz.Action routeAction(String route) {
|
||||||
|
return routeAction(route, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As above, plus the one route whose action depends on the body: {@code POST
|
||||||
|
* /sessions/{id}/message} carries a {@code turnId} (the answer-a-blocked-worker shape) or not
|
||||||
|
* (a plain delivery), mirroring {@code FleetMcp#sendAction}'s split of the same two call
|
||||||
|
* shapes over MCP. {@code turnId} is ignored by every other route.
|
||||||
|
*
|
||||||
|
* @param turnId the request body's {@code turnId}, or {@code null}/blank when absent or not
|
||||||
|
* applicable to this route
|
||||||
|
*/
|
||||||
|
static Authz.Action routeAction(String route, String turnId) {
|
||||||
return switch (route) {
|
return switch (route) {
|
||||||
case "GET /metrics" -> Authz.Action.METRICS;
|
case "GET /metrics" -> Authz.Action.METRICS;
|
||||||
case "POST /members" -> Authz.Action.SPAWN;
|
case "POST /members" -> Authz.Action.SPAWN;
|
||||||
case "DELETE /members/{paneId}" -> Authz.Action.STOP;
|
case "DELETE /members/{paneId}" -> Authz.Action.STOP;
|
||||||
case "POST /sessions/{id}/message" -> Authz.Action.SEND;
|
case "POST /sessions/{id}/message" -> turnId == null || turnId.isBlank()
|
||||||
|
? Authz.Action.SEND : Authz.Action.ANSWER;
|
||||||
case "POST /sessions/{id}/reply" -> Authz.Action.REPLY;
|
case "POST /sessions/{id}/reply" -> Authz.Action.REPLY;
|
||||||
case "GET /sessions/{id}/replies" -> Authz.Action.DRAIN;
|
case "GET /sessions/{id}/replies" -> Authz.Action.DRAIN;
|
||||||
case "POST /sessions/{id}/ask" -> Authz.Action.ASK;
|
case "POST /sessions/{id}/ask" -> Authz.Action.ASK;
|
||||||
case "GET /sessions", "GET /agents", "GET /members", "GET /profiles",
|
case "GET /sessions", "GET /agents", "GET /members", "GET /profiles",
|
||||||
"GET /member-credentials", "GET /sessions/{id}/status", "GET /tasks/{ticket}" -> Authz.Action.READ;
|
"GET /member-credentials" -> Authz.Action.READ;
|
||||||
|
case "GET /sessions/{id}/status", "GET /tasks/{ticket}" -> Authz.Action.TASK_READ;
|
||||||
default -> throw new IllegalArgumentException("route has no authorization gate: " + route);
|
default -> throw new IllegalArgumentException("route has no authorization gate: " + route);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The second gate for {@code POST /sessions/{id}/message}: checked only when {@code turnId}
|
||||||
|
* is present and non-blank, against {@link Authz.Action#ANSWER}. A request with no {@code
|
||||||
|
* turnId} passes this gate unconditionally, without consulting {@code permit} at all, having
|
||||||
|
* already cleared the coarse {@link Authz.Action#SEND} grant checked ahead of it.
|
||||||
|
*
|
||||||
|
* @param permit reports whether the caller holds the named grant
|
||||||
|
*/
|
||||||
|
static boolean answerGatePasses(String turnId, Predicate<Authz.Action> permit) {
|
||||||
|
return turnId == null || turnId.isBlank() || permit.test(Authz.Action.ANSWER);
|
||||||
|
}
|
||||||
|
|
||||||
/** Default blocking window for a message; kept under typical HTTP idle timeouts. */
|
/** Default blocking window for a message; kept under typical HTTP idle timeouts. */
|
||||||
private static final long DEFAULT_MESSAGE_TIMEOUT_MS = 25_000;
|
private static final long DEFAULT_MESSAGE_TIMEOUT_MS = 25_000;
|
||||||
private static final long MAX_MESSAGE_TIMEOUT_MS = 120_000;
|
private static final long MAX_MESSAGE_TIMEOUT_MS = 120_000;
|
||||||
@@ -236,6 +266,17 @@ public final class FleetApp {
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The authorization decision behind {@link #allow}, taking the caller directly rather than
|
||||||
|
* pulling it from a servlet {@link Context} — unit-testable without fabricating a live
|
||||||
|
* request, the same reason {@code FleetMcp#denyFor} is split from {@code FleetMcp#deny}. The
|
||||||
|
* classifier is the same shared instance {@link #allow} would use, so a test calling this
|
||||||
|
* exercises the real production gate, not a test-supplied stand-in.
|
||||||
|
*/
|
||||||
|
static boolean permitsFor(Principal caller, Authz.Action action, String target) {
|
||||||
|
return Authz.permits(caller, action, target, Authz.NO_KNOWN_LEAD_OR_COLLABORATOR);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gate a handler on the CB-505 authorization table. Returns {@code true} when the request may
|
* Gate a handler on the CB-505 authorization table. Returns {@code true} when the request may
|
||||||
* proceed; otherwise writes the error response and returns {@code false}.
|
* proceed; otherwise writes the error response and returns {@code false}.
|
||||||
@@ -249,8 +290,9 @@ public final class FleetApp {
|
|||||||
return true; // legacy: authorization not enforced
|
return true; // legacy: authorization not enforced
|
||||||
}
|
}
|
||||||
Principal caller = ctx.attribute(CALLER);
|
Principal caller = ctx.attribute(CALLER);
|
||||||
if (Authz.permits(caller, action, target)) {
|
if (permitsFor(caller, action, target)) {
|
||||||
if (action != Authz.Action.READ && action != Authz.Action.METRICS) {
|
if (action != Authz.Action.READ && action != Authz.Action.METRICS
|
||||||
|
&& action != Authz.Action.TASK_READ) {
|
||||||
AuditLog.allowed(caller, action, target); // reads would drown the trail
|
AuditLog.allowed(caller, action, target); // reads would drown the trail
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -603,26 +645,37 @@ public final class FleetApp {
|
|||||||
* status-gated injector and block until the worker returns a structured {@code fleet_reply}.
|
* status-gated injector and block until the worker returns a structured {@code fleet_reply}.
|
||||||
* Times out with a typed 202 (working / queued / busy) rather than an error — the message may
|
* Times out with a typed 202 (working / queued / busy) rather than an error — the message may
|
||||||
* still land.
|
* still land.
|
||||||
|
*
|
||||||
|
* <p>Two call shapes share this route, exactly as {@code fleet_send} does over MCP (see
|
||||||
|
* {@code FleetMcp#sendAction}): a plain delivery to {@code id}, and -- when the body carries
|
||||||
|
* {@code turnId} -- resolving a worker's blocked question. The coarse {@link
|
||||||
|
* Authz.Action#SEND} grant is checked first, before the body is read at all; only once that
|
||||||
|
* passes is the body parsed, and a present {@code turnId} is then checked again against
|
||||||
|
* {@link Authz.Action#ANSWER}. A body that fails to parse is rejected with 400 and reaches
|
||||||
|
* neither {@code messages.answer} nor {@code messages.send}.
|
||||||
*/
|
*/
|
||||||
private void sendMessage(Context ctx) {
|
private void sendMessage(Context ctx) {
|
||||||
String id = ctx.pathParam("id");
|
String id = ctx.pathParam("id");
|
||||||
if (!allow(ctx, routeAction("POST /sessions/{id}/message"), id)) {
|
if (!allow(ctx, routeAction("POST /sessions/{id}/message"), id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String content;
|
JsonNode body;
|
||||||
String turnId;
|
|
||||||
long timeout;
|
|
||||||
boolean wait;
|
|
||||||
try {
|
try {
|
||||||
JsonNode body = mapper.readTree(ctx.body());
|
body = mapper.readTree(ctx.body());
|
||||||
content = body.path("content").asText("");
|
|
||||||
turnId = body.path("turnId").asText(null);
|
|
||||||
timeout = body.path("timeoutMs").asLong(DEFAULT_MESSAGE_TIMEOUT_MS);
|
|
||||||
wait = body.path("wait").asBoolean(true); // default: block for the reply (CB-104)
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
if (body == null) {
|
||||||
ctx.status(400).json(Map.of("error", "bad_request", "detail", "body must be JSON"));
|
ctx.status(400).json(Map.of("error", "bad_request", "detail", "body must be JSON"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
String turnId = body.path("turnId").asText(null);
|
||||||
|
if (!answerGatePasses(turnId, action -> allow(ctx, action, id))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String content = body.path("content").asText("");
|
||||||
|
long timeout = body.path("timeoutMs").asLong(DEFAULT_MESSAGE_TIMEOUT_MS);
|
||||||
|
boolean wait = body.path("wait").asBoolean(true); // default: block for the reply (CB-104)
|
||||||
if (content.isBlank()) {
|
if (content.isBlank()) {
|
||||||
ctx.status(400).json(Map.of("error", "bad_request", "detail", "content is required"));
|
ctx.status(400).json(Map.of("error", "bad_request", "detail", "content is required"));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package dev.ltms.fleet;
|
||||||
|
|
||||||
|
import dev.ltms.fleet.auth.Authz;
|
||||||
|
import dev.ltms.fleet.auth.Principal;
|
||||||
|
import dev.ltms.fleet.config.ConfigRef;
|
||||||
|
import dev.ltms.fleet.config.FleetConfig;
|
||||||
|
import dev.ltms.fleet.guard.SubscriptionGuard;
|
||||||
|
import dev.ltms.fleet.herdr.FakeHerdr;
|
||||||
|
import dev.ltms.fleet.herdr.HerdrClient;
|
||||||
|
import dev.ltms.fleet.mcp.FleetMcp;
|
||||||
|
import dev.ltms.fleet.msg.ReplyInbox;
|
||||||
|
import io.javalin.Javalin;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.function.LongSupplier;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the {@link FleetMcp} built by {@link FleetdAssembly#assembleAndStart} applies the
|
||||||
|
* authorization table: a worker is refused {@code SPAWN}, and the primary is allowed it.
|
||||||
|
*/
|
||||||
|
class FleetdAssemblyAuthorizationModeTest {
|
||||||
|
|
||||||
|
private static final class TestResourcePorts implements ResourcePorts {
|
||||||
|
final FakeHerdr herdr = new FakeHerdr();
|
||||||
|
Runnable shutdownHook;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, String> environment() {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HerdrClient connectHerdr(Path socketPath) {
|
||||||
|
return herdr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Fleetd.AmqpOpener replyInboxOpener() {
|
||||||
|
return (uri, prefetch) -> new ReplyInbox() {
|
||||||
|
@Override public void own(String target) { }
|
||||||
|
@Override public void release(String target) { }
|
||||||
|
@Override public void publish(String target, String msgId, String content) { }
|
||||||
|
@Override public List<InboxMessage> peek(String target) { return List.of(); }
|
||||||
|
@Override public boolean ack(String target, String msgId) { return false; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Fleetd.LeadMailboxOpener leadMailboxOpener() {
|
||||||
|
return (uri, selfCoordId, prefetch) -> {
|
||||||
|
throw new UnsupportedOperationException(
|
||||||
|
"leadMailboxOpener must not be called — no coordinator: block is configured");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LongSupplier nanoClock() {
|
||||||
|
return System::nanoTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LongSupplier wallClockNanos() {
|
||||||
|
return System::nanoTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ScheduledExecutorService newScheduler(String purpose) {
|
||||||
|
return Executors.newSingleThreadScheduledExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addShutdownHook(Runnable hook) {
|
||||||
|
shutdownHook = hook;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void startHttp(Javalin app, String host, int port) {
|
||||||
|
// Binding a real port would clash with any daemon already listening on it.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Runnable herdrPollWait() {
|
||||||
|
return () -> {
|
||||||
|
throw new UnsupportedOperationException("FakeHerdr is healthy; no poll wait is expected");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TestResourcePorts ports;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (ports != null && ports.shutdownHook != null) {
|
||||||
|
ports.shutdownHook.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FleetConfig writeConfig(Path dir) throws Exception {
|
||||||
|
Path file = dir.resolve("fleetd.yaml");
|
||||||
|
Files.writeString(file, """
|
||||||
|
bind:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 8765
|
||||||
|
idleSleepGuard:
|
||||||
|
enabled: false
|
||||||
|
health:
|
||||||
|
enabled: false
|
||||||
|
broker:
|
||||||
|
uri: "amqp://fake-test-broker/vh"
|
||||||
|
""");
|
||||||
|
return FleetConfig.load(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FleetMcp assemble(Path dir) throws Exception {
|
||||||
|
FleetConfig cfg = writeConfig(dir);
|
||||||
|
ports = new TestResourcePorts();
|
||||||
|
FleetdRuntime runtime = FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg,
|
||||||
|
new ConfigRef(dir.resolve("fleetd.yaml"), cfg), new SubscriptionGuard(cfg.guard().hostSet())), ports);
|
||||||
|
return runtime.mcp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invokes {@code FleetMcp#denyFor}, which is package-private to {@code dev.ltms.fleet.mcp}
|
||||||
|
* while this test is in {@code dev.ltms.fleet}. Nothing here catches a missing method: if
|
||||||
|
* {@code denyFor} is renamed or removed, {@link NoSuchMethodException} propagates and the
|
||||||
|
* test fails.
|
||||||
|
*/
|
||||||
|
private static McpSchema.CallToolResult denyFor(FleetMcp mcp, Principal caller, Authz.Action action,
|
||||||
|
String target) throws Exception {
|
||||||
|
Method m = FleetMcp.class.getDeclaredMethod("denyFor", Principal.class, Authz.Action.class, String.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
return (McpSchema.CallToolResult) m.invoke(mcp, caller, action, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void productionBootPathRefusesAnUnauthorizedCallerThroughTheAssembledFleetMcp(@TempDir Path dir)
|
||||||
|
throws Exception {
|
||||||
|
FleetMcp mcp = assemble(dir);
|
||||||
|
|
||||||
|
McpSchema.CallToolResult deniedForWorker = denyFor(mcp, Principal.worker("term_a", 200),
|
||||||
|
Authz.Action.SPAWN, "term_a");
|
||||||
|
assertNotNull(deniedForWorker,
|
||||||
|
"a worker must not be able to fleet_spawn through the assembled FleetMcp");
|
||||||
|
assertTrue(deniedForWorker.isError(), "a refusal is returned as an MCP tool error");
|
||||||
|
|
||||||
|
McpSchema.CallToolResult allowedForPrimary = denyFor(mcp, Principal.primary(100),
|
||||||
|
Authz.Action.SPAWN, "term_a");
|
||||||
|
assertNull(allowedForPrimary,
|
||||||
|
"control: the primary must still be allowed to fleet_spawn — otherwise the worker "
|
||||||
|
+ "refusal above would pass even with the gate wired backwards");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package dev.ltms.fleet;
|
||||||
|
|
||||||
|
import dev.ltms.fleet.config.ConfigRef;
|
||||||
|
import dev.ltms.fleet.config.FleetConfig;
|
||||||
|
import dev.ltms.fleet.guard.SubscriptionGuard;
|
||||||
|
import dev.ltms.fleet.herdr.FakeHerdr;
|
||||||
|
import dev.ltms.fleet.herdr.HerdrClient;
|
||||||
|
import dev.ltms.fleet.herdr.LeadTabScanner;
|
||||||
|
import dev.ltms.fleet.msg.LeadChannelHandle;
|
||||||
|
import dev.ltms.fleet.msg.LeadCoordLoop;
|
||||||
|
import dev.ltms.fleet.msg.LeadMessage;
|
||||||
|
import dev.ltms.fleet.msg.ReplyInbox;
|
||||||
|
import io.javalin.Javalin;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.function.LongSupplier;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #670 — pins the {@code excludedWorkspaceLabels} argument {@link FleetdAssembly}'s
|
||||||
|
* production boot path passes to {@link LeadTabScanner} at {@code FleetdAssembly.java:265}
|
||||||
|
* ({@code Set.of()}).
|
||||||
|
*
|
||||||
|
* <p>{@code LeadTabScannerTest} already covers this constructor parameter, but it builds its own
|
||||||
|
* {@link LeadTabScanner} with its own set, so it tests the seam and proves nothing about the
|
||||||
|
* producer. This test instead reaches the exact object {@link FleetdAssembly#assembleAndStart}
|
||||||
|
* builds: a {@code fleet.leaders:} block makes the assembly construct a real
|
||||||
|
* {@link LeadTabScanner} for its local {@code leads} supplier, and a {@code coordinator:} block
|
||||||
|
* makes it hand that same supplier instance to {@link LeadCoordLoop} (fleetd #637), which stores
|
||||||
|
* it as a field. Reflection recovers it from there, and then from the scanner itself, so the
|
||||||
|
* assertion is against the real production argument rather than a copy built for this test.
|
||||||
|
*/
|
||||||
|
class FleetdAssemblyLeadTabScannerExclusionTest {
|
||||||
|
|
||||||
|
private static final class FakeLeadChannel implements LeadChannelHandle {
|
||||||
|
@Override
|
||||||
|
public void publish(String toCoordId, LeadMessage message) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<LeadMessage> peek() {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void ack(String msgId) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String selfCoordId() {
|
||||||
|
return "test-lead";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean heldDurable() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MailboxState inspect(String coordId) {
|
||||||
|
return MailboxState.unknown(coordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class TestResourcePorts implements ResourcePorts {
|
||||||
|
final FakeHerdr herdr = new FakeHerdr();
|
||||||
|
Runnable shutdownHook;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, String> environment() {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HerdrClient connectHerdr(Path socketPath) {
|
||||||
|
return herdr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Fleetd.AmqpOpener replyInboxOpener() {
|
||||||
|
return (uri, prefetch) -> new ReplyInbox() {
|
||||||
|
@Override public void own(String target) { }
|
||||||
|
@Override public void release(String target) { }
|
||||||
|
@Override public void publish(String target, String msgId, String content) { }
|
||||||
|
@Override public List<InboxMessage> peek(String target) { return List.of(); }
|
||||||
|
@Override public boolean ack(String target, String msgId) { return false; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Fleetd.LeadMailboxOpener leadMailboxOpener() {
|
||||||
|
return (uri, selfCoordId, prefetch) -> new FakeLeadChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LongSupplier nanoClock() {
|
||||||
|
return System::nanoTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LongSupplier wallClockNanos() {
|
||||||
|
return System::nanoTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ScheduledExecutorService newScheduler(String purpose) {
|
||||||
|
return Executors.newSingleThreadScheduledExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addShutdownHook(Runnable hook) {
|
||||||
|
shutdownHook = hook;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void startHttp(Javalin app, String host, int port) {
|
||||||
|
// Do not bind a real port in this assembly test.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Runnable herdrPollWait() {
|
||||||
|
return () -> {
|
||||||
|
throw new UnsupportedOperationException("FakeHerdr is healthy; no poll wait is expected");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FleetConfig writeConfig(Path dir) throws Exception {
|
||||||
|
Path file = dir.resolve("fleetd.yaml");
|
||||||
|
Files.writeString(file, """
|
||||||
|
bind:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 8765
|
||||||
|
idleSleepGuard:
|
||||||
|
enabled: false
|
||||||
|
coordinator:
|
||||||
|
uri: "amqp://fake-lead-broker/vh"
|
||||||
|
selfId: "test-lead"
|
||||||
|
fleet:
|
||||||
|
leaders:
|
||||||
|
primary:
|
||||||
|
tab: "lead: primary"
|
||||||
|
profile: sonnet
|
||||||
|
profiles:
|
||||||
|
sonnet:
|
||||||
|
subscription: true
|
||||||
|
argv: ["ccs", "sonnet"]
|
||||||
|
""");
|
||||||
|
return FleetConfig.load(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void productionBootPathPassesNoExcludedWorkspaceLabels(@TempDir Path dir) throws Exception {
|
||||||
|
FleetConfig cfg = writeConfig(dir);
|
||||||
|
TestResourcePorts ports = new TestResourcePorts();
|
||||||
|
FleetdRuntime runtime = FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg,
|
||||||
|
new ConfigRef(dir.resolve("fleetd.yaml"), cfg), new SubscriptionGuard(cfg.guard().hostSet())), ports);
|
||||||
|
try {
|
||||||
|
LeadCoordLoop coordLoop = runtime.leadCoordLoop();
|
||||||
|
assertNotNull(coordLoop, "control: a configured coordinator: block must build LeadCoordLoop");
|
||||||
|
|
||||||
|
Field leadsField = LeadCoordLoop.class.getDeclaredField("leads");
|
||||||
|
leadsField.setAccessible(true);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Supplier<Map<String, String>> leads = (Supplier<Map<String, String>>) leadsField.get(coordLoop);
|
||||||
|
|
||||||
|
assertInstanceOf(LeadTabScanner.class, leads,
|
||||||
|
"control: a non-empty fleet.leaders: block must make FleetdAssembly build a real "
|
||||||
|
+ "LeadTabScanner for its `leads` supplier, not the Map::of fallback — "
|
||||||
|
+ "otherwise this test would pass for the wrong reason");
|
||||||
|
|
||||||
|
Field excludedField = LeadTabScanner.class.getDeclaredField("excludedWorkspaceLabels");
|
||||||
|
excludedField.setAccessible(true);
|
||||||
|
Set<?> excluded = (Set<?>) excludedField.get(leads);
|
||||||
|
|
||||||
|
assertTrue(excluded.isEmpty(),
|
||||||
|
"FleetdAssembly.java:265 must pass an empty excludedWorkspaceLabels to "
|
||||||
|
+ "LeadTabScanner — scanning member tabs would demote the lead to a worker");
|
||||||
|
} finally {
|
||||||
|
assertNotNull(ports.shutdownHook, "control: assembly must capture its shutdown hook");
|
||||||
|
ports.shutdownHook.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package dev.ltms.fleet;
|
||||||
|
|
||||||
|
import dev.ltms.fleet.config.ConfigRef;
|
||||||
|
import dev.ltms.fleet.config.FleetConfig;
|
||||||
|
import dev.ltms.fleet.guard.SubscriptionGuard;
|
||||||
|
import dev.ltms.fleet.herdr.FakeHerdr;
|
||||||
|
import dev.ltms.fleet.herdr.HerdrClient;
|
||||||
|
import dev.ltms.fleet.inject.Injector;
|
||||||
|
import dev.ltms.fleet.inject.StatusPoller;
|
||||||
|
import dev.ltms.fleet.msg.LeadChannelHandle;
|
||||||
|
import dev.ltms.fleet.msg.LeadCoordLoop;
|
||||||
|
import dev.ltms.fleet.msg.LeadMessage;
|
||||||
|
import dev.ltms.fleet.msg.ReplyInbox;
|
||||||
|
import dev.ltms.fleet.msg.ReplyPushLoop;
|
||||||
|
import io.javalin.Javalin;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.function.LongSupplier;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the assembled loops use the production reminder, coordination, and delivery timing
|
||||||
|
* defaults when no {@code primary:} block configures the reply-push values.
|
||||||
|
*/
|
||||||
|
class FleetdAssemblyTimingDefaultsTest {
|
||||||
|
|
||||||
|
private static final class FakeLeadChannel implements LeadChannelHandle {
|
||||||
|
@Override
|
||||||
|
public void publish(String toCoordId, LeadMessage message) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<LeadMessage> peek() {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void ack(String msgId) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String selfCoordId() {
|
||||||
|
return "test-lead";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean heldDurable() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MailboxState inspect(String coordId) {
|
||||||
|
return MailboxState.unknown(coordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class TestResourcePorts implements ResourcePorts {
|
||||||
|
final FakeHerdr herdr = new FakeHerdr();
|
||||||
|
Runnable shutdownHook;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, String> environment() {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HerdrClient connectHerdr(Path socketPath) {
|
||||||
|
return herdr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Fleetd.AmqpOpener replyInboxOpener() {
|
||||||
|
return (uri, prefetch) -> new ReplyInbox() {
|
||||||
|
@Override public void own(String target) { }
|
||||||
|
@Override public void release(String target) { }
|
||||||
|
@Override public void publish(String target, String msgId, String content) { }
|
||||||
|
@Override public List<InboxMessage> peek(String target) { return List.of(); }
|
||||||
|
@Override public boolean ack(String target, String msgId) { return false; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Fleetd.LeadMailboxOpener leadMailboxOpener() {
|
||||||
|
return (uri, selfCoordId, prefetch) -> new FakeLeadChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LongSupplier nanoClock() {
|
||||||
|
return System::nanoTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LongSupplier wallClockNanos() {
|
||||||
|
return System::nanoTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ScheduledExecutorService newScheduler(String purpose) {
|
||||||
|
return Executors.newSingleThreadScheduledExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addShutdownHook(Runnable hook) {
|
||||||
|
shutdownHook = hook;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void startHttp(Javalin app, String host, int port) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Runnable herdrPollWait() {
|
||||||
|
return () -> {
|
||||||
|
throw new UnsupportedOperationException("FakeHerdr is healthy; no poll wait is expected");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TestResourcePorts ports;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (ports != null && ports.shutdownHook != null) {
|
||||||
|
ports.shutdownHook.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FleetConfig writeConfig(Path dir) throws Exception {
|
||||||
|
Path file = dir.resolve("fleetd.yaml");
|
||||||
|
Files.writeString(file, """
|
||||||
|
bind:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 8765
|
||||||
|
idleSleepGuard:
|
||||||
|
enabled: false
|
||||||
|
coordinator:
|
||||||
|
uri: "amqp://fake-lead-broker/vh"
|
||||||
|
selfId: "test-lead"
|
||||||
|
""");
|
||||||
|
return FleetConfig.load(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FleetdRuntime assemble(Path dir) throws Exception {
|
||||||
|
FleetConfig cfg = writeConfig(dir);
|
||||||
|
ports = new TestResourcePorts();
|
||||||
|
return FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg,
|
||||||
|
new ConfigRef(dir.resolve("fleetd.yaml"), cfg), new SubscriptionGuard(cfg.guard().hostSet())), ports);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long longField(Object target, String name) throws Exception {
|
||||||
|
Field field = target.getClass().getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
return field.getLong(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void productionBootPathUsesTheExpectedLoopTimingDefaults(@TempDir Path dir) throws Exception {
|
||||||
|
FleetdRuntime runtime = assemble(dir);
|
||||||
|
|
||||||
|
ReplyPushLoop pushLoop = runtime.pushLoop();
|
||||||
|
assertEquals(5, longField(pushLoop, "maxReminders"),
|
||||||
|
"without primary:, ReplyPushLoop must stop after five reminder attempts");
|
||||||
|
assertEquals(15_000L, longField(pushLoop, "backoffMs"),
|
||||||
|
"without primary:, ReplyPushLoop must wait fifteen seconds before the next reminder");
|
||||||
|
|
||||||
|
LeadCoordLoop leadCoordLoop = runtime.leadCoordLoop();
|
||||||
|
assertNotNull(leadCoordLoop, "control: coordinator: must build LeadCoordLoop");
|
||||||
|
assertEquals(3_000L, longField(leadCoordLoop, "intervalMs"),
|
||||||
|
"LeadCoordLoop must poll for peer-lead mail every three seconds");
|
||||||
|
|
||||||
|
StatusPoller poller = runtime.poller();
|
||||||
|
assertEquals(Injector.POLL_INTERVAL_MILLIS, longField(poller, "intervalMillis"),
|
||||||
|
"StatusPoller must use Injector's delivery poll interval");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ class AuthzTest {
|
|||||||
private static final Principal ANON = Principal.anonymous();
|
private static final Principal ANON = Principal.anonymous();
|
||||||
private static final Principal ARCH_DESIGN = Principal.architect("lead-designer", "term_design", 400);
|
private static final Principal ARCH_DESIGN = Principal.architect("lead-designer", "term_design", 400);
|
||||||
private static final Principal ARCH_OTHER = Principal.architect("reviewer", "term_review", 500);
|
private static final Principal ARCH_OTHER = Principal.architect("reviewer", "term_review", 500);
|
||||||
|
private static final Principal COLLABORATOR = Principal.collaborator("ops", "term_collab", 600);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void anonymousIsAuthorizedForNothing() {
|
void anonymousIsAuthorizedForNothing() {
|
||||||
@@ -38,6 +39,37 @@ class AuthzTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code fleet_send} is three call shapes behind one action name until {@code
|
||||||
|
* FleetMcp#sendAction} picks one: a plain local {@link Authz.Action#SEND}, the {@code coordId}
|
||||||
|
* route ({@link Authz.Action#COORD_SEND}), and the {@code turnId} answer form ({@link
|
||||||
|
* Authz.Action#ANSWER}). All three carry the same grant as the undivided action did — a worker
|
||||||
|
* is excluded from every one, exactly as it was excluded from the one combined action before.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void theThreeSendShapesCarryTheSameGrantAsTheOldUndividedAction() {
|
||||||
|
for (Authz.Action a : new Authz.Action[]{SEND, COORD_SEND, ANSWER}) {
|
||||||
|
assertTrue(Authz.permits(PRIMARY, a, "term_a"), "the primary may " + a);
|
||||||
|
assertTrue(Authz.permits(ARCH_DESIGN, a, "term_a"), "an architect may " + a);
|
||||||
|
assertFalse(Authz.permits(WORKER_A, a, "term_a"),
|
||||||
|
"a worker performing " + a + " would be escalating into the orchestrator role");
|
||||||
|
assertFalse(Authz.permits(ANON, a, "term_a"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code fleet_poll{ticket}} and {@code fleet_status} are {@link Authz.Action#TASK_READ}, split
|
||||||
|
* out of the roster-only {@link Authz.Action#READ} (fleetd #678). The grant is unchanged from
|
||||||
|
* what the undivided {@code READ} action gave every one of these callers.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void taskReadCarriesTheSameGrantReadDidBeforeTheSplit() {
|
||||||
|
assertTrue(Authz.permits(PRIMARY, TASK_READ, null));
|
||||||
|
assertTrue(Authz.permits(WORKER_A, TASK_READ, null));
|
||||||
|
assertTrue(Authz.permits(ARCH_DESIGN, TASK_READ, null));
|
||||||
|
assertFalse(Authz.permits(ANON, TASK_READ, null));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void aWorkerMayReplyAndAskOnlyAsItself() {
|
void aWorkerMayReplyAndAskOnlyAsItself() {
|
||||||
assertTrue(Authz.permits(WORKER_A, REPLY, "term_a"));
|
assertTrue(Authz.permits(WORKER_A, REPLY, "term_a"));
|
||||||
@@ -135,4 +167,87 @@ class AuthzTest {
|
|||||||
assertFalse(Authz.isUnauthenticated(WORKER_A));
|
assertFalse(Authz.isUnauthenticated(WORKER_A));
|
||||||
assertFalse(Authz.isUnauthenticated(PRIMARY));
|
assertFalse(Authz.isUnauthenticated(PRIMARY));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── the collaborator matrix ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code SEND} for a collaborator is the one grant that is conditional rather than fixed:
|
||||||
|
* flipping only the classifier's answer for the target flips only this outcome.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aCollaboratorMaySendOnlyWhenTheClassifierAcceptsTheTarget() {
|
||||||
|
assertTrue(Authz.permits(COLLABORATOR, SEND, "term_lead", target -> true),
|
||||||
|
"the classifier accepting the target must grant SEND");
|
||||||
|
assertFalse(Authz.permits(COLLABORATOR, SEND, "term_lead", target -> false),
|
||||||
|
"the classifier refusing the target must deny SEND");
|
||||||
|
assertFalse(Authz.permits(COLLABORATOR, SEND, "term_lead"),
|
||||||
|
"the real production classifier recognises no terminal yet, so SEND is refused today");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control for the test above: every other action's result for a collaborator does not move
|
||||||
|
* when the classifier does. Only {@code SEND} is wired to it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void theClassifierMovesOnlySendForACollaborator() {
|
||||||
|
for (Authz.Action a : Authz.Action.values()) {
|
||||||
|
if (a == SEND) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
Authz.permits(COLLABORATOR, a, "term_lead"),
|
||||||
|
Authz.permits(COLLABORATOR, a, "term_lead", target -> true),
|
||||||
|
a + " must not depend on the classifier at all");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aCollaboratorMayReadAndScrapeMetrics() {
|
||||||
|
assertTrue(Authz.permits(COLLABORATOR, READ, null));
|
||||||
|
assertTrue(Authz.permits(COLLABORATOR, METRICS, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aCollaboratorMayReplyAndAskOnlyAsItsOwnPane() {
|
||||||
|
assertTrue(Authz.permits(COLLABORATOR, REPLY, "term_collab"),
|
||||||
|
"its own pane is its own");
|
||||||
|
assertTrue(Authz.permits(COLLABORATOR, ASK, "term_collab"));
|
||||||
|
|
||||||
|
assertFalse(Authz.permits(COLLABORATOR, REPLY, "term_design"),
|
||||||
|
"a collaborator must not reply on another pane");
|
||||||
|
assertFalse(Authz.permits(COLLABORATOR, REPLY, null),
|
||||||
|
"an absent target must not pass the own-session rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every action denied to a collaborator, asserted denied even when the classifier would
|
||||||
|
* accept any target — proving none of these is actually gated on the classifier at all.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aCollaboratorIsDeniedLifecycleCoordinationAndTicketPolling() {
|
||||||
|
for (Authz.Action a : new Authz.Action[]{SPAWN, STOP, DRAIN, HANDOVER, ANSWER, COORD_SEND,
|
||||||
|
COORD_READ, TASK_READ}) {
|
||||||
|
assertFalse(Authz.permits(COLLABORATOR, a, "term_lead", target -> true),
|
||||||
|
"a collaborator must not " + a + " even when the classifier accepts every target");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aCollaboratorIsNotCountedAsPrimaryWorkerOrArchitect() {
|
||||||
|
assertFalse(COLLABORATOR.isPrimary());
|
||||||
|
assertFalse(COLLABORATOR.isWorker());
|
||||||
|
assertFalse(COLLABORATOR.isArchitect());
|
||||||
|
assertTrue(COLLABORATOR.isCollaborator());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collaborator is never spawned, so it must not be enrolled in the presence map as an
|
||||||
|
* available member. Control: both a worker and an architect — which ARE spawned — still are.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void isSpawnedMemberIsFalseForACollaboratorButTrueForAWorkerAndAnArchitect() {
|
||||||
|
assertFalse(COLLABORATOR.isSpawnedMember());
|
||||||
|
assertTrue(WORKER_A.isSpawnedMember());
|
||||||
|
assertTrue(ARCH_DESIGN.isSpawnedMember());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -676,12 +676,8 @@ class FleetConfigTest {
|
|||||||
assertEquals(5, hb.quietNudgeCap());
|
assertEquals(5, hb.quietNudgeCap());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The hazard the guard exists for: fleetd writes worker tab labels and reads lead tab labels.
|
|
||||||
* Overlap the two and every worker it spawns is read back as a lead.
|
|
||||||
*/
|
|
||||||
@Test
|
@Test
|
||||||
void aLeadPrefixThatAProfileTabLabelOverrideAlsoMatchesRefusesToStart(@TempDir Path dir)
|
void aProfileTabLabelOverrideMatchingALeadTabRefusesToStart(@TempDir Path dir)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
Path f = dir.resolve("collide.yaml");
|
Path f = dir.resolve("collide.yaml");
|
||||||
Files.writeString(f, """
|
Files.writeString(f, """
|
||||||
@@ -689,32 +685,33 @@ class FleetConfigTest {
|
|||||||
port: 8080
|
port: 8080
|
||||||
profiles:
|
profiles:
|
||||||
gx10:
|
gx10:
|
||||||
tabLabel: "lead: {profile} #{n}"
|
tabLabel: "alpha"
|
||||||
fleet:
|
fleet:
|
||||||
leaders:
|
leaders:
|
||||||
opus:
|
opus:
|
||||||
tab: "lead: opus"
|
tab: "alpha"
|
||||||
tabPrefix: "lead:"
|
|
||||||
""");
|
""");
|
||||||
FleetConfig cfg = FleetConfig.load(f);
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
IllegalStateException e =
|
IllegalStateException e =
|
||||||
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||||
assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile");
|
assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile");
|
||||||
|
assertTrue(e.getMessage().contains("alpha"), "the message must name the offending label");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A bad fleet-wide template promotes every member, not one profile — so it is checked too. */
|
|
||||||
@Test
|
@Test
|
||||||
void aFleetTabLabelThatMatchesALeadPrefixRefusesToStart(@TempDir Path dir) throws Exception {
|
void aFleetTabLabelTemplateThatCanRenderAsALeadTabRefusesToStart(@TempDir Path dir) throws Exception {
|
||||||
Path f = dir.resolve("collide-template.yaml");
|
Path f = dir.resolve("collide-template.yaml");
|
||||||
Files.writeString(f, """
|
Files.writeString(f, """
|
||||||
bind:
|
bind:
|
||||||
port: 8080
|
port: 8080
|
||||||
|
profiles:
|
||||||
|
pha: {}
|
||||||
fleet:
|
fleet:
|
||||||
tabLabel: "lead: {role} {profile}"
|
tabLabel: "al{profile}"
|
||||||
leaders:
|
leaders:
|
||||||
opus:
|
opus:
|
||||||
tab: "lead: opus"
|
tab: "alpha"
|
||||||
""");
|
""");
|
||||||
FleetConfig cfg = FleetConfig.load(f);
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
@@ -723,12 +720,28 @@ class FleetConfigTest {
|
|||||||
assertTrue(e.getMessage().contains("fleet.tabLabel"));
|
assertTrue(e.getMessage().contains("fleet.tabLabel"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The point of making role the label's first field: {@code {role}} comes from a closed enum, so
|
|
||||||
* a generated label cannot begin with {@code "lead:"} however the fleet is configured.
|
|
||||||
*/
|
|
||||||
@Test
|
@Test
|
||||||
void theDefaultTabLabelCannotCollideWithTheDefaultLeadPrefix(@TempDir Path dir) throws Exception {
|
void anExactFleetTabLabelCollisionRefusesToStart(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("exact-tab-collision.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
tabLabel: "alpha"
|
||||||
|
leaders:
|
||||||
|
alpha:
|
||||||
|
tab: "alpha"
|
||||||
|
""");
|
||||||
|
|
||||||
|
IllegalStateException e = assertThrows(IllegalStateException.class,
|
||||||
|
() -> FleetConfig.load(f).validateAll());
|
||||||
|
assertTrue(e.getMessage().contains("fleet.tabLabel"),
|
||||||
|
"the message must name the offending label");
|
||||||
|
assertTrue(e.getMessage().contains("alpha"), "the message must name the colliding lead tab");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aFleetTabLabelTemplateThatCannotRenderAsALeadTabIsAllowed(@TempDir Path dir) throws Exception {
|
||||||
Path f = dir.resolve("ok.yaml");
|
Path f = dir.resolve("ok.yaml");
|
||||||
Files.writeString(f, """
|
Files.writeString(f, """
|
||||||
bind:
|
bind:
|
||||||
@@ -737,17 +750,13 @@ class FleetConfigTest {
|
|||||||
gx10:
|
gx10:
|
||||||
baseUrl: http://gx00.gw:8000
|
baseUrl: http://gx00.gw:8000
|
||||||
fleet:
|
fleet:
|
||||||
|
tabLabel: "worker-{profile}"
|
||||||
leaders:
|
leaders:
|
||||||
opus:
|
opus:
|
||||||
tab: "lead: opus"
|
tab: "alpha"
|
||||||
""");
|
""");
|
||||||
|
|
||||||
assertDoesNotThrow(() -> FleetConfig.load(f).validateLeadTabPrefixes());
|
assertDoesNotThrow(() -> FleetConfig.load(f).validateAll());
|
||||||
for (MemberRole role : MemberRole.values()) {
|
|
||||||
assertFalse(FleetConfig.Fleet.DEFAULT_TAB_LABEL
|
|
||||||
.replace("{role}", role.wireName()).startsWith("lead:"),
|
|
||||||
"no role renders a label that reads as a lead");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -765,6 +774,78 @@ class FleetConfigTest {
|
|||||||
"a label that collides with a convention nobody reads is not a problem");
|
"a label that collides with a convention nobody reads is not a problem");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #677: identity is matched on a lead's exact {@code tab} alone, so two leads sharing
|
||||||
|
* one tab means only one of them is ever found — the guard must catch this independently of
|
||||||
|
* the member-template checks above.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void twoLeadsSharingTheSameExactTabRefusesToStart(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("shared-tab.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
leaders:
|
||||||
|
opus:
|
||||||
|
tab: "shared tab"
|
||||||
|
sonnet:
|
||||||
|
tab: "shared tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e =
|
||||||
|
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||||
|
assertTrue(e.getMessage().contains("opus"), "the message must name one offending lead");
|
||||||
|
assertTrue(e.getMessage().contains("sonnet"), "the message must name the other offending lead");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #693: the guard matches tabs case-insensitively, because
|
||||||
|
* {@code LeadTabScanner} keys its tab map on a lowercased label — two tabs differing only in
|
||||||
|
* case collide there too, and the guard must catch that independently of the exact-match case
|
||||||
|
* above.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void twoLeadsSharingTheSameTabInDifferentCaseRefusesToStart(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("shared-tab-case.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
leaders:
|
||||||
|
opus:
|
||||||
|
tab: "Shared Tab"
|
||||||
|
sonnet:
|
||||||
|
tab: "shared tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e =
|
||||||
|
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||||
|
assertTrue(e.getMessage().contains("opus"), "the message must name one offending lead");
|
||||||
|
assertTrue(e.getMessage().contains("sonnet"), "the message must name the other offending lead");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control for {@link #twoLeadsSharingTheSameExactTabRefusesToStart}: distinct tabs load cleanly. */
|
||||||
|
@Test
|
||||||
|
void twoLeadsWithDistinctExactTabsAreAllowed(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("distinct-tabs.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
leaders:
|
||||||
|
opus:
|
||||||
|
tab: "opus tab"
|
||||||
|
sonnet:
|
||||||
|
tab: "sonnet tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
assertDoesNotThrow(cfg::validateLeadTabPrefixes);
|
||||||
|
}
|
||||||
|
|
||||||
// ── validatePanePlacementAgainstLeadTabs ────────────────────────────────────────────────────
|
// ── validatePanePlacementAgainstLeadTabs ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -919,6 +1000,273 @@ class FleetConfigTest {
|
|||||||
"no primary.terminal pin ⇒ nothing registered, even with fleet.leaders configured");
|
"no primary.terminal pin ⇒ nothing registered, even with fleet.leaders configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── fleetd #669: the collaborators registry ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code Fleet} is {@code @JsonIgnoreProperties(ignoreUnknown = true)}, so a config naming
|
||||||
|
* {@code fleet.collaborators.<name>.tab} loads with no exception whether or not the key is
|
||||||
|
* ever read into the object model. Asserting only "no exception" would pass both before and
|
||||||
|
* after the real fix, so this asserts the parsed value is actually reachable from the loaded
|
||||||
|
* {@code FleetConfig} — the one thing a vacuous "no exception" test cannot tell apart.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void collaboratorsBlockIsActuallyParsedNotSilentlyDropped(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("collaborators.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
reviewer-alex:
|
||||||
|
tab: "collab: alex"
|
||||||
|
""");
|
||||||
|
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
assertEquals("collab: alex", cfg.fleet().collaborators().get("reviewer-alex").tab());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A collaborator carries no field other than {@code tab}, so a blank one is meaningless. */
|
||||||
|
@Test
|
||||||
|
void aCollaboratorWithNoTabRefusesToStart(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("useless-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
ghost: {}
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
|
||||||
|
assertTrue(e.getMessage().contains("ghost"), "the message must name the useless entry");
|
||||||
|
assertTrue(e.getMessage().contains("tab:"), "the message must say what is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control for {@link #aCollaboratorWithNoTabRefusesToStart}: a named tab loads cleanly. */
|
||||||
|
@Test
|
||||||
|
void aCollaboratorWithATabIsAllowed(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("named-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
reviewer-alex:
|
||||||
|
tab: "collab: alex"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
assertDoesNotThrow(cfg::validateMembers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669: the fleet-wide {@code tabLabel} template can render as a collaborator tab, the
|
||||||
|
* same hazard {@link #aFleetTabLabelTemplateThatCanRenderAsALeadTabRefusesToStart} covers on
|
||||||
|
* the lead side. Drives the fleet-wide branch directly, with no profile override involved.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aFleetTabLabelTemplateThatCanRenderAsACollaboratorTabRefusesToStart(@TempDir Path dir)
|
||||||
|
throws Exception {
|
||||||
|
Path f = dir.resolve("collide-template-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
tabLabel: "al{profile}"
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "alpha"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e =
|
||||||
|
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||||
|
assertTrue(e.getMessage().contains("fleet.tabLabel"));
|
||||||
|
assertTrue(e.getMessage().contains("alex"), "the message must name the offending collaborator");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669: a member tabLabel that can render as a configured collaborator tab is the same
|
||||||
|
* hazard as the lead case above — a member labelled that way is read back as the collaborator.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aProfileTabLabelOverrideMatchingACollaboratorTabRefusesToStart(@TempDir Path dir)
|
||||||
|
throws Exception {
|
||||||
|
Path f = dir.resolve("collide-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
profiles:
|
||||||
|
gx10:
|
||||||
|
tabLabel: "collab-tab"
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab-tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e =
|
||||||
|
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||||
|
assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile");
|
||||||
|
assertTrue(e.getMessage().contains("collab-tab"), "the message must name the offending label");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control: a profile tabLabel that cannot render as the collaborator tab is allowed. */
|
||||||
|
@Test
|
||||||
|
void aProfileTabLabelThatCannotRenderAsACollaboratorTabIsAllowed(@TempDir Path dir)
|
||||||
|
throws Exception {
|
||||||
|
Path f = dir.resolve("ok-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
profiles:
|
||||||
|
gx10:
|
||||||
|
tabLabel: "worker-{profile}"
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab-tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
assertDoesNotThrow(cfg::validateLeadTabPrefixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fleetd #669: identity is matched on a collaborator's exact tab, so two sharing one are unreachable. */
|
||||||
|
@Test
|
||||||
|
void twoCollaboratorsSharingTheSameExactTabRefusesToStart(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("shared-collaborator-tab.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "shared tab"
|
||||||
|
sam:
|
||||||
|
tab: "Shared Tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e =
|
||||||
|
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||||
|
assertTrue(e.getMessage().contains("alex"), "the message must name one offending collaborator");
|
||||||
|
assertTrue(e.getMessage().contains("sam"), "the message must name the other offending collaborator");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control for {@link #twoCollaboratorsSharingTheSameExactTabRefusesToStart}: distinct tabs load cleanly. */
|
||||||
|
@Test
|
||||||
|
void twoCollaboratorsWithDistinctExactTabsAreAllowed(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("distinct-collaborator-tabs.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "alex tab"
|
||||||
|
sam:
|
||||||
|
tab: "sam tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
assertDoesNotThrow(cfg::validateLeadTabPrefixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669: a collaborator tab equal to a lead tab crosses a privilege boundary — the worst
|
||||||
|
* of the three new collisions, since only one of the two identities is ever found.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aCollaboratorTabEqualToALeadTabRefusesToStart(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("lead-collaborator-collision.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
leaders:
|
||||||
|
opus:
|
||||||
|
tab: "shared tab"
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "Shared Tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e =
|
||||||
|
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
|
||||||
|
assertTrue(e.getMessage().contains("opus"), "the message must name the offending lead");
|
||||||
|
assertTrue(e.getMessage().contains("alex"), "the message must name the offending collaborator");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control for {@link #aCollaboratorTabEqualToALeadTabRefusesToStart}: distinct tabs load cleanly. */
|
||||||
|
@Test
|
||||||
|
void aLeadAndACollaboratorWithDistinctTabsAreAllowed(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("lead-collaborator-ok.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
leaders:
|
||||||
|
opus:
|
||||||
|
tab: "lead tab"
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab tab"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
assertDoesNotThrow(cfg::validateLeadTabPrefixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669: a pane-placed member can land in a collaborator's labelled tab exactly as it
|
||||||
|
* can land in a lead's — {@code validatePanePlacementAgainstLeadTabs()} must fire even when
|
||||||
|
* {@code fleet.leaders} is empty, which is the early-return the brief flagged as the bug.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aPanePlacedProfileWithACollaboratorTabRefusesToStartEvenWithNoLeaders(@TempDir Path dir)
|
||||||
|
throws Exception {
|
||||||
|
Path f = dir.resolve("pane-hazard-collaborator.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
profiles:
|
||||||
|
gx10:
|
||||||
|
placement: pane
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab: alex"
|
||||||
|
""");
|
||||||
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
|
|
||||||
|
IllegalStateException e = assertThrows(IllegalStateException.class,
|
||||||
|
cfg::validatePanePlacementAgainstLeadTabs);
|
||||||
|
assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control: a pane-placed profile with no lead or collaborator tab configured is allowed. */
|
||||||
|
@Test
|
||||||
|
void aPanePlacedProfileWithNoLeaderOrCollaboratorTabIsAllowed(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("pane-no-tab-at-all.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
profiles:
|
||||||
|
gx10:
|
||||||
|
placement: pane
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex: {}
|
||||||
|
""");
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> FleetConfig.load(f).validatePanePlacementAgainstLeadTabs(),
|
||||||
|
"a collaborator with no tab feeds nothing into the scanner, so pane placement is safe");
|
||||||
|
}
|
||||||
|
|
||||||
// ── CB-548: the architects registry ────────────────────────────────────────────────────────
|
// ── CB-548: the architects registry ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1191,6 +1539,60 @@ class FleetConfigTest {
|
|||||||
assertEquals(Set.of("sonnet"), cfg.fleet().pool(MemberRole.REVIEWER).keySet());
|
assertEquals(Set.of("sonnet"), cfg.fleet().pool(MemberRole.REVIEWER).keySet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A duplicated name in {@code fleet.collaborators} is refused at parse time, like any other
|
||||||
|
* {@code fleet:} pool. See {@link #duplicateSlotNamesInOnePoolAreRejectedAtParseTime}.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void duplicateCollaboratorNamesAreRejectedAtParseTime(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("collaborator-dup.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
fleet:
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab: alex"
|
||||||
|
alex:
|
||||||
|
tab: "collab: alex, second"
|
||||||
|
""");
|
||||||
|
|
||||||
|
IllegalStateException e =
|
||||||
|
assertThrows(IllegalStateException.class, () -> FleetConfig.load(f));
|
||||||
|
assertTrue(e.getMessage().contains("alex"),
|
||||||
|
"the refusal names the duplicated entry, was: " + e.getMessage());
|
||||||
|
assertTrue(e.getMessage().contains("fleet.collaborators"),
|
||||||
|
"the refusal names the pool the duplicate is in, was: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control for {@link #duplicateCollaboratorNamesAreRejectedAtParseTime}: the same name reused
|
||||||
|
* across the collaborators registry and a member role pool is the role × profile matrix doing
|
||||||
|
* its job in the other pool, not a mistake — only a repeat within one pool loses an entry.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void theSameNameInCollaboratorsAndAnotherPoolIsNotADuplicate(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("collaborator-cross-pool.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
profiles:
|
||||||
|
sonnet:
|
||||||
|
baseUrl: http://gx10.gw:8000
|
||||||
|
fleet:
|
||||||
|
architects:
|
||||||
|
alex:
|
||||||
|
profile: sonnet
|
||||||
|
collaborators:
|
||||||
|
alex:
|
||||||
|
tab: "collab: alex"
|
||||||
|
""");
|
||||||
|
|
||||||
|
FleetConfig cfg = assertDoesNotThrow(() -> FleetConfig.load(f));
|
||||||
|
assertEquals(Set.of("alex"), cfg.fleet().pool(MemberRole.ARCHITECT).keySet());
|
||||||
|
assertEquals(Set.of("alex"), cfg.fleet().collaborators().keySet());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void duplicateKeysOutsideTheFleetPoolsAreUnaffected(@TempDir Path dir) throws Exception {
|
void duplicateKeysOutsideTheFleetPoolsAreUnaffected(@TempDir Path dir) throws Exception {
|
||||||
// The duplicate check is scoped to the fleet pools — a duplicate elsewhere is not this
|
// The duplicate check is scoped to the fleet pools — a duplicate elsewhere is not this
|
||||||
@@ -3175,4 +3577,41 @@ class FleetConfigTest {
|
|||||||
FleetConfig cfg = FleetConfig.load(f);
|
FleetConfig cfg = FleetConfig.load(f);
|
||||||
assertTrue(cfg.models().offIds().isEmpty());
|
assertTrue(cfg.models().offIds().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── fleetd #651: leadRollover.turnSettleSeconds default resolution ─────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void turnSettleSecondsDefaultsTo300WhenUnset(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("bare-rollover.yaml");
|
||||||
|
Files.writeString(f, "bind:\n port: 8080\nleadRollover: {}\n");
|
||||||
|
|
||||||
|
FleetConfig.LeadRollover rollover = FleetConfig.load(f).leadRollover();
|
||||||
|
assertNotNull(rollover);
|
||||||
|
assertEquals(300, rollover.turnSettleSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void turnSettleSecondsUsesAnExplicitPositiveValue(@TempDir Path dir) throws Exception {
|
||||||
|
Path f = dir.resolve("rollover.yaml");
|
||||||
|
Files.writeString(f, """
|
||||||
|
bind:
|
||||||
|
port: 8080
|
||||||
|
leadRollover:
|
||||||
|
turnSettleSeconds: 45
|
||||||
|
""");
|
||||||
|
|
||||||
|
FleetConfig.LeadRollover rollover = FleetConfig.load(f).leadRollover();
|
||||||
|
assertEquals(45, rollover.turnSettleSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void turnSettleSecondsFallsBackTo300WhenZeroOrNegative(@TempDir Path dir) throws Exception {
|
||||||
|
Path zero = dir.resolve("zero.yaml");
|
||||||
|
Files.writeString(zero, "bind:\n port: 8080\nleadRollover:\n turnSettleSeconds: 0\n");
|
||||||
|
assertEquals(300, FleetConfig.load(zero).leadRollover().turnSettleSeconds());
|
||||||
|
|
||||||
|
Path negative = dir.resolve("negative.yaml");
|
||||||
|
Files.writeString(negative, "bind:\n port: 8080\nleadRollover:\n turnSettleSeconds: -5\n");
|
||||||
|
assertEquals(300, FleetConfig.load(negative).leadRollover().turnSettleSeconds());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,63 +17,21 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The gap this class exists to close: mutation testing on the fleetd ticket "central allow-list
|
* Tests the reflective validator sweep and {@link FleetConfig#validateAll()} reachability.
|
||||||
* of usable models" found that although {@link FleetConfig#validateModels()}'s own logic was well
|
|
||||||
* pinned, nothing proved either real caller ({@code Fleetd.main} and {@link ConfigRef#reload()})
|
|
||||||
* still invoked it — deleting the call site left the full suite green (1478/0/0/0). A follow-up
|
|
||||||
* measurement (same technique — remove one call site, run the suite, not read the code) found the
|
|
||||||
* SAME gap for all five of {@link FleetConfig}'s other validators at startup, and for four of the
|
|
||||||
* six inside {@link ConfigRef#reload()}. This is a class of gap, not one line's mistake: every one
|
|
||||||
* of those thirteen tests called the validator itself directly, never the real caller that was
|
|
||||||
* supposed to.
|
|
||||||
*
|
*
|
||||||
* <p>The fix replaces the six individual {@code cfg.validateXxx()} calls at each of the two real
|
* <p>{@link #theSweepRunsEveryValidateMethodOnAnUnrelatedClass()} and its neighbours
|
||||||
* call sites with one {@link FleetConfig#validateAll()}, which reaches every validator by
|
* prove that {@link FleetConfig#invokeAllValidators} runs each public, no-arg, void
|
||||||
* reflection rather than by a hand-maintained list of names. A hand-maintained list of six names
|
* {@code validateXxx()} method on its target. {@link #fleetConfigDeclaresExactlyTheseValidatorsToday()}
|
||||||
* would have exactly the defect it replaces: the seventh validator someone adds next month has no
|
* is the canary for the validator set. {@link #validateAllReachesEveryOneOfTodaysRealValidators()}
|
||||||
* reason to be added to it, and nothing would say so. This class proves TWO separate claims, and
|
* is the reachability check for that set.
|
||||||
* keeps them separate on purpose:
|
|
||||||
*
|
|
||||||
* <ol>
|
|
||||||
* <li>{@link #theSweepMechanismIsGenericNotHardcodedToFleetConfigsSixNames()} and its neighbours
|
|
||||||
* prove the reflective sweep itself ({@link FleetConfig#invokeAllValidators}) is a general
|
|
||||||
* mechanism — it runs whatever public, no-arg, void {@code validateXxx()} methods a class
|
|
||||||
* happens to declare today, including a class with more of them than {@link FleetConfig}
|
|
||||||
* has right now. This is the proof that a future, real seventh validator on {@link
|
|
||||||
* FleetConfig} would be swept automatically, without needing to add a real (unwanted)
|
|
||||||
* seventh validator just to exercise the claim.</li>
|
|
||||||
* <li>{@link #validateAllReachesEveryOneOfTodaysRealValidators()} proves {@link
|
|
||||||
* FleetConfig#validateAll()} itself is wired to that same generic mechanism and genuinely
|
|
||||||
* reaches seven of today's eight real validators — reusing the exact minimal failing
|
|
||||||
* configurations {@code FleetConfigTest} already established for each one directly, so a
|
|
||||||
* single call to {@code validateAll()} is shown to reproduce every one of those seven
|
|
||||||
* failures. The eighth, {@link FleetConfig#validateLeadRollover()}, has no case here yet —
|
|
||||||
* a pre-existing gap tracked as fleetd #668.</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
|
||||||
* <p>Together with the direct-{@code Fleetd.main}-invocation tests in {@code
|
|
||||||
* FleetdStartupValidationTest} (which prove the real startup call site still calls {@code
|
|
||||||
* validateAll()}) and the {@code ConfigRefTest} reload tests (which prove the same for {@link
|
|
||||||
* ConfigRef#reload()}), removing {@code cfg.validateAll();} from either real call site now fails
|
|
||||||
* a test in this module.
|
|
||||||
*
|
|
||||||
* <p><b>What is NOT pinned, measured rather than assumed.</b> Reverting {@link
|
|
||||||
* FleetConfig#validateAll()} to a hardcoded list of today's six method calls leaves the whole
|
|
||||||
* suite green (measured at review: 1491 tests, 0 failures). Nothing ties {@code validateAll()} to
|
|
||||||
* the generic sweep — claim 1 proves {@link FleetConfig#invokeAllValidators} is generic, and claim
|
|
||||||
* 2 proves {@code validateAll()} reaches today's six, and a hardcoded list satisfies both. So the
|
|
||||||
* reflective sweep is a convenience, not the guarantee. The guarantee is {@link
|
|
||||||
* #fleetConfigDeclaresExactlyTheseValidatorsToday()}: it fails the moment any validator is added
|
|
||||||
* or removed, which forces whoever changes the set to look at this file.
|
|
||||||
*/
|
*/
|
||||||
class FleetConfigValidateAllTest {
|
class FleetConfigValidateAllTest {
|
||||||
|
|
||||||
// ── Claim 1: the reflective sweep is a general mechanism, not six names in disguise ──────────
|
// ── Claim 1: the reflective sweep is a general mechanism ─────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A throwaway fixture class, unrelated to {@link FleetConfig} in every way except shape: three
|
* Fixture with public, no-arg, void methods named {@code validateXxx}. It proves the sweep uses
|
||||||
* public, no-arg, void methods named {@code validateXxx}. Proves the sweep works on ANY class
|
* the target's method shape rather than special handling for {@link FleetConfig}.
|
||||||
* with this shape, not on something special-cased to {@link FleetConfig}.
|
|
||||||
*/
|
*/
|
||||||
static class ThreeValidators {
|
static class ThreeValidators {
|
||||||
final List<String> ran = new ArrayList<>();
|
final List<String> ran = new ArrayList<>();
|
||||||
@@ -92,7 +50,7 @@ class FleetConfigValidateAllTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void theSweepMechanismIsGenericNotHardcodedToFleetConfigsSixNames() {
|
void theSweepRunsEveryValidateMethodOnAnUnrelatedClass() {
|
||||||
ThreeValidators target = new ThreeValidators();
|
ThreeValidators target = new ThreeValidators();
|
||||||
FleetConfig.invokeAllValidators(target);
|
FleetConfig.invokeAllValidators(target);
|
||||||
assertEquals(List.of("validateAlpha", "validateBeta", "validateGamma"), target.ran,
|
assertEquals(List.of("validateAlpha", "validateBeta", "validateGamma"), target.ran,
|
||||||
@@ -102,12 +60,8 @@ class FleetConfigValidateAllTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The core of the "self-maintaining" requirement: the exact same class shape as {@link
|
* Fixture with an added valid method. It proves the sweep reaches a method because it matches
|
||||||
* ThreeValidators}, plus one more method — standing in for "a developer adds a validator next
|
* the validator shape.
|
||||||
* month". Nothing about the sweep changes to pick it up; the new method is invoked purely
|
|
||||||
* because it exists and matches the shape. This is what makes adding a seventh real validator
|
|
||||||
* to {@link FleetConfig} safe without touching {@link FleetConfig#validateAll()} or either
|
|
||||||
* call site — there is no "wire it in" step left to forget.
|
|
||||||
*/
|
*/
|
||||||
static class FourValidators {
|
static class FourValidators {
|
||||||
final List<String> ran = new ArrayList<>();
|
final List<String> ran = new ArrayList<>();
|
||||||
@@ -135,7 +89,7 @@ class FleetConfigValidateAllTest {
|
|||||||
FleetConfig.invokeAllValidators(target);
|
FleetConfig.invokeAllValidators(target);
|
||||||
assertEquals(List.of("validateAlpha", "validateBeta", "validateDelta", "validateGamma"),
|
assertEquals(List.of("validateAlpha", "validateBeta", "validateDelta", "validateGamma"),
|
||||||
sorted(target.ran),
|
sorted(target.ran),
|
||||||
"the fourth method must be reached automatically — proving a class can grow the "
|
"the added method must be reached automatically — proving a class can grow the "
|
||||||
+ "set of things it validates with no change to the sweep itself");
|
+ "set of things it validates with no change to the sweep itself");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +164,7 @@ class FleetConfigValidateAllTest {
|
|||||||
+ "name) must all be skipped");
|
+ "name) must all be skipped");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Claim 2: FleetConfig.validateAll() is wired to that mechanism and reaches seven of eight today ──
|
// ── Claim 2: FleetConfig.validateAll() is wired to that mechanism and reaches every validator ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reflectively enumerates {@link FleetConfig}'s own public, no-arg, void {@code validateXxx()}
|
* Reflectively enumerates {@link FleetConfig}'s own public, no-arg, void {@code validateXxx()}
|
||||||
@@ -220,6 +174,11 @@ class FleetConfigValidateAllTest {
|
|||||||
* the {@code Set.of} below, so a reader adding or removing one sees this assertion name the new
|
* the {@code Set.of} below, so a reader adding or removing one sees this assertion name the new
|
||||||
* count rather than a silent pass at the old one. The count lives only in that set, not in this
|
* count rather than a silent pass at the old one. The count lives only in that set, not in this
|
||||||
* method's name, so the two cannot drift apart.
|
* method's name, so the two cannot drift apart.
|
||||||
|
*
|
||||||
|
* <p>This assertion alone proves only that the validator exists with the right shape — it
|
||||||
|
* cannot prove {@code validateAll()} actually reaches it. Only {@link
|
||||||
|
* #validateAllReachesEveryOneOfTodaysRealValidators()} proves reachability, which is why this
|
||||||
|
* method's failure message sends the reader there too.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void fleetConfigDeclaresExactlyTheseValidatorsToday() {
|
void fleetConfigDeclaresExactlyTheseValidatorsToday() {
|
||||||
@@ -237,11 +196,16 @@ class FleetConfigValidateAllTest {
|
|||||||
"validateSubscriptionProfiles", "validateCharters", "validateMembers",
|
"validateSubscriptionProfiles", "validateCharters", "validateMembers",
|
||||||
"validateModels", "validateLeadRollover", "validatePanePlacementAgainstLeadTabs")),
|
"validateModels", "validateLeadRollover", "validatePanePlacementAgainstLeadTabs")),
|
||||||
names,
|
names,
|
||||||
"FleetConfig's public validate*() methods changed. Do TWO things, in this "
|
"FleetConfig's public validate*() methods changed. Do THREE things, in this "
|
||||||
+ "order. First confirm validateAll() still delegates to "
|
+ "order. First confirm validateAll() still delegates to "
|
||||||
+ "invokeAllValidators(this) — a hardcoded list there passes every other "
|
+ "invokeAllValidators(this) — a hardcoded list there passes every other "
|
||||||
+ "test in this class, so this assertion is the only place that will ever "
|
+ "test in this class, so this assertion is the only place that will ever "
|
||||||
+ "make you check. Only then update the expected set to match.");
|
+ "make you check. Second, update the expected set below to match. Third, "
|
||||||
|
+ "add or remove a case for that validator in "
|
||||||
|
+ "validateAllReachesEveryOneOfTodaysRealValidators() below — this "
|
||||||
|
+ "assertion proves only that the validator exists with the right shape, "
|
||||||
|
+ "never that validateAll() reaches it; that enumeration is the test that "
|
||||||
|
+ "does.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A minimal, otherwise-valid file — same shape FleetConfigTest and ConfigRefTest use. */
|
/** A minimal, otherwise-valid file — same shape FleetConfigTest and ConfigRefTest use. */
|
||||||
@@ -265,14 +229,18 @@ class FleetConfigValidateAllTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The heart of claim 2: for seven of today's eight real validators, a minimal file that fails
|
* The heart of claim 2: for every one of today's real validators, a minimal file that
|
||||||
* ONLY that one — the exact fixtures {@code FleetConfigTest} uses to test each validator
|
* fails ONLY that one — the exact fixtures {@code FleetConfigTest} uses to test each validator
|
||||||
* directly — must also fail through {@link FleetConfig#validateAll()}. If a future edit to
|
* directly, or a dedicated minimal fixture where no other test drives that validator through
|
||||||
* {@code validateAll()} silently dropped one of these seven from the sweep (e.g. a typo'd name
|
* {@code validateAll()} — must also fail through {@link FleetConfig#validateAll()}. If a
|
||||||
* filter), exactly one of them would start passing when it must not.
|
* future edit to {@code validateAll()} silently dropped one of these from the sweep
|
||||||
|
* (e.g. a typo'd name filter), exactly one of them would start passing when it must not.
|
||||||
*
|
*
|
||||||
* <p>The eighth, {@link FleetConfig#validateLeadRollover()}, has no case here — a pre-existing
|
* <p>This is the single place that proves {@code validateAll()} reaches a given validator.
|
||||||
* gap tracked as fleetd #668, not fixed by this change.
|
* Adding or removing a validator on {@link FleetConfig} must add or remove a case here, not
|
||||||
|
* only an updated name in {@link #fleetConfigDeclaresExactlyTheseValidatorsToday()}'s expected
|
||||||
|
* set — that assertion proves the validator's shape, never that {@code validateAll()} reaches
|
||||||
|
* it.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void validateAllReachesEveryOneOfTodaysRealValidators(@TempDir Path dir) throws Exception {
|
void validateAllReachesEveryOneOfTodaysRealValidators(@TempDir Path dir) throws Exception {
|
||||||
@@ -283,16 +251,16 @@ class FleetConfigValidateAllTest {
|
|||||||
port: 8765
|
port: 8765
|
||||||
""", "auth.mode: token");
|
""", "auth.mode: token");
|
||||||
|
|
||||||
// validateLeadTabPrefixes: a fleet-wide tabLabel that starts with a lead's own tabPrefix.
|
// validateLeadTabPrefixes: a fleet-wide tabLabel that equals a lead tab.
|
||||||
assertValidateAllRefuses(dir, "lead-tab-prefixes.yaml", """
|
assertValidateAllRefuses(dir, "lead-tab-prefixes.yaml", """
|
||||||
bind:
|
bind:
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
port: 8765
|
port: 8765
|
||||||
fleet:
|
fleet:
|
||||||
tabLabel: "lead: {role} {profile}"
|
tabLabel: "alpha"
|
||||||
leaders:
|
leaders:
|
||||||
opus:
|
opus:
|
||||||
tab: "lead: opus"
|
tab: "alpha"
|
||||||
""", "fleet.tabLabel");
|
""", "fleet.tabLabel");
|
||||||
|
|
||||||
// validateSubscriptionProfiles: subscription: true with env: reseating ANTHROPIC_BASE_URL.
|
// validateSubscriptionProfiles: subscription: true with env: reseating ANTHROPIC_BASE_URL.
|
||||||
@@ -362,6 +330,15 @@ class FleetConfigValidateAllTest {
|
|||||||
opus:
|
opus:
|
||||||
tab: "lead: opus"
|
tab: "lead: opus"
|
||||||
""", "gx10");
|
""", "gx10");
|
||||||
|
|
||||||
|
// validateLeadRollover: a leadRollover: block present with no handoverPath.
|
||||||
|
assertValidateAllRefuses(dir, "lead-rollover.yaml", """
|
||||||
|
bind:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 8765
|
||||||
|
leadRollover:
|
||||||
|
requireOperatorConfirm: false
|
||||||
|
""", "handoverPath");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void assertValidateAllRefuses(Path dir, String fileName, String yaml,
|
private static void assertValidateAllRefuses(Path dir, String fileName, String yaml,
|
||||||
|
|||||||
@@ -81,12 +81,6 @@ class LeadContextGaugeHighThresholdTest {
|
|||||||
assertEquals(LeadContextGauge.State.HIGH,
|
assertEquals(LeadContextGauge.State.HIGH,
|
||||||
atGauge.read(atConfigDir, SESSION_ID, "claude", null).state(),
|
atGauge.read(atConfigDir, SESSION_ID, "claude", null).state(),
|
||||||
"the fixed default must still be 200,000 when no window is resolvable");
|
"the fixed default must still be 200,000 when no window is resolvable");
|
||||||
|
|
||||||
String legacyConfigDir = writeTranscript(tmp.resolve("legacy"), SESSION_ID, 200_000);
|
|
||||||
LeadContextGauge legacyGauge = new LeadContextGauge();
|
|
||||||
assertEquals(LeadContextGauge.State.HIGH,
|
|
||||||
legacyGauge.read(legacyConfigDir, SESSION_ID, "claude").state(),
|
|
||||||
"the 3-arg read() (no window argument at all) must behave exactly like passing a null window");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class LeadContextGaugeTest {
|
|||||||
usageLine(40_000, 5_000, 3_000)); // last record: 48,000
|
usageLine(40_000, 5_000, 3_000)); // last record: 48,000
|
||||||
LeadContextGauge gauge = new LeadContextGauge();
|
LeadContextGauge gauge = new LeadContextGauge();
|
||||||
|
|
||||||
LeadContextGauge.Reading first = gauge.read(configDir, SESSION_ID, "claude");
|
LeadContextGauge.Reading first = gauge.read(configDir, SESSION_ID, "claude", null);
|
||||||
assertEquals(48_000L, first.tokens(), "must total input+cache_read+cache_creation of the LAST usage record");
|
assertEquals(48_000L, first.tokens(), "must total input+cache_read+cache_creation of the LAST usage record");
|
||||||
assertEquals(LeadContextGauge.State.OK, first.state());
|
assertEquals(LeadContextGauge.State.OK, first.state());
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ class LeadContextGaugeTest {
|
|||||||
// must change with it, not stay pinned to the first fixture's total.
|
// must change with it, not stay pinned to the first fixture's total.
|
||||||
String otherSession = "22222222-2222-2222-2222-222222222222";
|
String otherSession = "22222222-2222-2222-2222-222222222222";
|
||||||
writeTranscript(tmp, otherSession, usageLine(100_000, 50_000, 50_000)); // last record: 200,000
|
writeTranscript(tmp, otherSession, usageLine(100_000, 50_000, 50_000)); // last record: 200,000
|
||||||
LeadContextGauge.Reading second = gauge.read(configDir, otherSession, "claude");
|
LeadContextGauge.Reading second = gauge.read(configDir, otherSession, "claude", null);
|
||||||
assertEquals(200_000L, second.tokens());
|
assertEquals(200_000L, second.tokens());
|
||||||
assertTrue(second.tokens() != first.tokens(), "changing N in the fixture must change the reported number");
|
assertTrue(second.tokens() != first.tokens(), "changing N in the fixture must change the reported number");
|
||||||
}
|
}
|
||||||
@@ -111,12 +111,12 @@ class LeadContextGaugeTest {
|
|||||||
compactionLine(),
|
compactionLine(),
|
||||||
usageLine(3_000, 0, 0));
|
usageLine(3_000, 0, 0));
|
||||||
LeadContextGauge gauge = new LeadContextGauge();
|
LeadContextGauge gauge = new LeadContextGauge();
|
||||||
LeadContextGauge.Reading twoCompactions = gauge.read(tmp.toString(), sessionTwoCompactions, "claude");
|
LeadContextGauge.Reading twoCompactions = gauge.read(tmp.toString(), sessionTwoCompactions, "claude", null);
|
||||||
assertEquals(2, twoCompactions.compactions());
|
assertEquals(2, twoCompactions.compactions());
|
||||||
|
|
||||||
String sessionZeroCompactions = "44444444-4444-4444-4444-444444444444";
|
String sessionZeroCompactions = "44444444-4444-4444-4444-444444444444";
|
||||||
writeTranscript(tmp, sessionZeroCompactions, usageLine(3_000, 0, 0));
|
writeTranscript(tmp, sessionZeroCompactions, usageLine(3_000, 0, 0));
|
||||||
LeadContextGauge.Reading zeroCompactions = gauge.read(tmp.toString(), sessionZeroCompactions, "claude");
|
LeadContextGauge.Reading zeroCompactions = gauge.read(tmp.toString(), sessionZeroCompactions, "claude", null);
|
||||||
assertEquals(0, zeroCompactions.compactions(), "changing K in the fixture must change the reported count");
|
assertEquals(0, zeroCompactions.compactions(), "changing K in the fixture must change the reported count");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ class LeadContextGaugeTest {
|
|||||||
@DisplayName("a missing transcript file reports UNKNOWN with no token number")
|
@DisplayName("a missing transcript file reports UNKNOWN with no token number")
|
||||||
void missingFileIsUnknown(@TempDir Path tmp) {
|
void missingFileIsUnknown(@TempDir Path tmp) {
|
||||||
LeadContextGauge gauge = new LeadContextGauge();
|
LeadContextGauge gauge = new LeadContextGauge();
|
||||||
LeadContextGauge.Reading reading = gauge.read(tmp.toString(), SESSION_ID, "claude");
|
LeadContextGauge.Reading reading = gauge.read(tmp.toString(), SESSION_ID, "claude", null);
|
||||||
assertEquals(LeadContextGauge.State.UNKNOWN, reading.state());
|
assertEquals(LeadContextGauge.State.UNKNOWN, reading.state());
|
||||||
assertNull(reading.tokens());
|
assertNull(reading.tokens());
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,7 @@ class LeadContextGaugeTest {
|
|||||||
assumeFalse(Files.isReadable(file),
|
assumeFalse(Files.isReadable(file),
|
||||||
"runs as root (CI container): the read bit does not stop root, so this case cannot be set up here");
|
"runs as root (CI container): the read bit does not stop root, so this case cannot be set up here");
|
||||||
LeadContextGauge gauge = new LeadContextGauge();
|
LeadContextGauge gauge = new LeadContextGauge();
|
||||||
LeadContextGauge.Reading reading = gauge.read(configDir, SESSION_ID, "claude");
|
LeadContextGauge.Reading reading = gauge.read(configDir, SESSION_ID, "claude", null);
|
||||||
assertEquals(LeadContextGauge.State.UNKNOWN, reading.state());
|
assertEquals(LeadContextGauge.State.UNKNOWN, reading.state());
|
||||||
assertNull(reading.tokens());
|
assertNull(reading.tokens());
|
||||||
} finally {
|
} finally {
|
||||||
@@ -171,7 +171,7 @@ class LeadContextGaugeTest {
|
|||||||
Files.writeString(file, lastCompleteLine + "\n" + tornLine, StandardCharsets.UTF_8);
|
Files.writeString(file, lastCompleteLine + "\n" + tornLine, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
LeadContextGauge gauge = new LeadContextGauge();
|
LeadContextGauge gauge = new LeadContextGauge();
|
||||||
LeadContextGauge.Reading reading = gauge.read(tmp.toString(), SESSION_ID, "claude");
|
LeadContextGauge.Reading reading = gauge.read(tmp.toString(), SESSION_ID, "claude", null);
|
||||||
assertEquals(LeadContextGauge.State.OK, reading.state(),
|
assertEquals(LeadContextGauge.State.OK, reading.state(),
|
||||||
"a torn final line must not turn a good earlier reading into UNKNOWN");
|
"a torn final line must not turn a good earlier reading into UNKNOWN");
|
||||||
assertEquals(6_000L, reading.tokens(),
|
assertEquals(6_000L, reading.tokens(),
|
||||||
@@ -187,7 +187,7 @@ class LeadContextGaugeTest {
|
|||||||
"{this is not json at all",
|
"{this is not json at all",
|
||||||
"neither is this{{{");
|
"neither is this{{{");
|
||||||
LeadContextGauge gauge = new LeadContextGauge();
|
LeadContextGauge gauge = new LeadContextGauge();
|
||||||
LeadContextGauge.Reading reading = gauge.read(configDir, SESSION_ID, "claude");
|
LeadContextGauge.Reading reading = gauge.read(configDir, SESSION_ID, "claude", null);
|
||||||
assertEquals(LeadContextGauge.State.UNKNOWN, reading.state(),
|
assertEquals(LeadContextGauge.State.UNKNOWN, reading.state(),
|
||||||
"every line unparseable is the real format-change signal and must still report UNKNOWN");
|
"every line unparseable is the real format-change signal and must still report UNKNOWN");
|
||||||
assertNull(reading.tokens());
|
assertNull(reading.tokens());
|
||||||
@@ -224,12 +224,12 @@ class LeadContextGaugeTest {
|
|||||||
AtomicLong now = new AtomicLong(0);
|
AtomicLong now = new AtomicLong(0);
|
||||||
LeadContextGauge gauge = new LeadContextGauge(now::get, 5_000);
|
LeadContextGauge gauge = new LeadContextGauge(now::get, 5_000);
|
||||||
|
|
||||||
gauge.read(configDir, SESSION_ID, "claude");
|
gauge.read(configDir, SESSION_ID, "claude", null);
|
||||||
gauge.read(configDir, SESSION_ID, "claude"); // still inside the TTL window
|
gauge.read(configDir, SESSION_ID, "claude", null); // still inside the TTL window
|
||||||
assertEquals(1, gauge.diskReadCount(), "two reads inside the TTL must touch disk once");
|
assertEquals(1, gauge.diskReadCount(), "two reads inside the TTL must touch disk once");
|
||||||
|
|
||||||
now.set(6_000); // past the TTL
|
now.set(6_000); // past the TTL
|
||||||
gauge.read(configDir, SESSION_ID, "claude");
|
gauge.read(configDir, SESSION_ID, "claude", null);
|
||||||
assertEquals(2, gauge.diskReadCount(), "a read past the TTL must touch disk again");
|
assertEquals(2, gauge.diskReadCount(), "a read past the TTL must touch disk again");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,8 +241,8 @@ class LeadContextGaugeTest {
|
|||||||
String configDir = writeTranscript(tmp, SESSION_ID, usageLine(1_000, 0, 0));
|
String configDir = writeTranscript(tmp, SESSION_ID, usageLine(1_000, 0, 0));
|
||||||
LeadContextGauge gauge = new LeadContextGauge();
|
LeadContextGauge gauge = new LeadContextGauge();
|
||||||
|
|
||||||
assertEquals(LeadContextGauge.State.UNKNOWN, gauge.read(configDir, SESSION_ID, "opencode").state());
|
assertEquals(LeadContextGauge.State.UNKNOWN, gauge.read(configDir, SESSION_ID, "opencode", null).state());
|
||||||
assertEquals(LeadContextGauge.State.UNKNOWN, gauge.read(configDir, SESSION_ID, null).state());
|
assertEquals(LeadContextGauge.State.UNKNOWN, gauge.read(configDir, SESSION_ID, null, null).state());
|
||||||
assertEquals(LeadContextGauge.State.UNKNOWN, gauge.read(configDir, null, "claude").state());
|
assertEquals(LeadContextGauge.State.UNKNOWN, gauge.read(configDir, null, "claude", null).state());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ class FleetMcpAuthzTest {
|
|||||||
private static final Principal WORKER_A = Principal.worker("term_a", 200);
|
private static final Principal WORKER_A = Principal.worker("term_a", 200);
|
||||||
private static final Principal ANON = Principal.anonymous();
|
private static final Principal ANON = Principal.anonymous();
|
||||||
private static final Principal ARCH_DESIGN = Principal.architect("lead-designer", "term_design", 400);
|
private static final Principal ARCH_DESIGN = Principal.architect("lead-designer", "term_design", 400);
|
||||||
|
private static final Principal COLLABORATOR = Principal.collaborator("ops", "term_collab", 600);
|
||||||
|
|
||||||
// --- the table, enforced on THIS path too ---------------------------------------------------
|
// --- the table, enforced on THIS path too ---------------------------------------------------
|
||||||
|
|
||||||
@@ -156,6 +157,45 @@ class FleetMcpAuthzTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669 Unit A: {@code SEND} is split into three actions ({@link Authz.Action#SEND},
|
||||||
|
* {@link Authz.Action#COORD_SEND}, {@link Authz.Action#ANSWER}), each carrying the same grant
|
||||||
|
* the one undivided action gave. An architect holds all three, exactly as it held the one.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void anArchitectMayUseAllThreeSendShapesOverMcp() {
|
||||||
|
FleetMcp m = mcp(true);
|
||||||
|
for (Authz.Action a : new Authz.Action[]{Authz.Action.SEND, Authz.Action.COORD_SEND,
|
||||||
|
Authz.Action.ANSWER}) {
|
||||||
|
assertNull(m.denyFor(ARCH_DESIGN, a, "term_a"),
|
||||||
|
a + " carries the same grant the undivided SEND action gave an architect");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The other half of the same split: a worker is excluded from all three, as it was from one. */
|
||||||
|
@Test
|
||||||
|
void aWorkerMayNotUseAnySendShapeOverMcp() {
|
||||||
|
FleetMcp m = mcp(true);
|
||||||
|
for (Authz.Action a : new Authz.Action[]{Authz.Action.SEND, Authz.Action.COORD_SEND,
|
||||||
|
Authz.Action.ANSWER}) {
|
||||||
|
McpSchema.CallToolResult denied = m.denyFor(WORKER_A, a, "term_a");
|
||||||
|
assertNotNull(denied, a + " must stay refused to a worker");
|
||||||
|
assertTrue(denied.isError(), "a refusal is returned as an MCP tool error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669 Unit A / #678: {@code TASK_READ} (ticket polling, session status) is split out of
|
||||||
|
* the roster-only {@code READ}, carrying forward the grant the undivided action gave. A worker
|
||||||
|
* still has both — it never gained or lost anything by the split.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aWorkerKeepsBothReadActionsAfterTheSplit() {
|
||||||
|
FleetMcp m = mcp(true);
|
||||||
|
assertNull(m.denyFor(WORKER_A, Authz.Action.READ, null));
|
||||||
|
assertNull(m.denyFor(WORKER_A, Authz.Action.TASK_READ, null));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void anArchitectMayReplyAndAskOnlyAsItsOwnPaneOverMcp() {
|
void anArchitectMayReplyAndAskOnlyAsItsOwnPaneOverMcp() {
|
||||||
FleetMcp m = mcp(true);
|
FleetMcp m = mcp(true);
|
||||||
@@ -187,6 +227,19 @@ class FleetMcpAuthzTest {
|
|||||||
"the caller IS authenticated — it is just not the right role");
|
"the caller IS authenticated — it is just not the right role");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code denyFor} passes the real production classifier, not a test-supplied one — no
|
||||||
|
* terminal is recognised as a configured lead or collaborator, so a collaborator's SEND is
|
||||||
|
* refused over MCP.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aCollaboratorMayNotSendOverMcpWithTheRealProductionClassifier() {
|
||||||
|
FleetMcp m = mcp(true);
|
||||||
|
McpSchema.CallToolResult denied = m.denyFor(COLLABORATOR, Authz.Action.SEND, "term_lead");
|
||||||
|
assertNotNull(denied, "no terminal is recognised as a lead or collaborator yet");
|
||||||
|
assertTrue(denied.isError());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void theLegacyConstructorLeavesTheGateOpen() {
|
void theLegacyConstructorLeavesTheGateOpen() {
|
||||||
// The 22 pre-existing FleetMcpTest cases rely on no authorization being enforced.
|
// The 22 pre-existing FleetMcpTest cases rely on no authorization being enforced.
|
||||||
@@ -297,35 +350,53 @@ class FleetMcpAuthzTest {
|
|||||||
* the whole time the defect was live -- the table was right, the action fed to it was wrong.
|
* the whole time the defect was live -- the table was right, the action fed to it was wrong.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void pollingByTargetIsADrainAndPollingByTicketIsARead() {
|
void pollingByTargetIsADrainAndPollingByTicketIsATaskRead() {
|
||||||
assertEquals(Authz.Action.DRAIN, FleetMcp.pollAction("term_b", null),
|
assertEquals(Authz.Action.DRAIN, FleetMcp.pollAction("term_b", null),
|
||||||
"poll by target removes the replies — that is a drain, not an observation");
|
"poll by target removes the replies — that is a drain, not an observation");
|
||||||
assertEquals(Authz.Action.READ, FleetMcp.pollAction(null, null),
|
assertEquals(Authz.Action.TASK_READ, FleetMcp.pollAction(null, null),
|
||||||
"poll by ticket changes nothing");
|
"poll by ticket changes nothing, but is not the roster-only READ action");
|
||||||
assertEquals(Authz.Action.READ, FleetMcp.pollAction(" ", null),
|
assertEquals(Authz.Action.TASK_READ, FleetMcp.pollAction(" ", null),
|
||||||
"a blank target is an absent target");
|
"a blank target is an absent target");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fleetd #421: a coordId branch is a THIRD operation behind fleet_poll's one name, and it must
|
* fleetd #421: a coordId branch is a THIRD operation behind fleet_poll's one name, and it must
|
||||||
* map to {@link Authz.Action#COORD_READ} — never {@link Authz.Action#READ}, even though this
|
* map to {@link Authz.Action#COORD_READ} — never {@link Authz.Action#READ} or {@link
|
||||||
* branch also consumes nothing. READ's grant is open to every authenticated role on the premise
|
* Authz.Action#TASK_READ}, even though this branch also consumes nothing. A lead-to-lead body
|
||||||
* that the roster carries no secrets; a lead-to-lead body is not the roster, so folding this
|
* is not the roster and not a ticket/status read, so folding this branch into either would let
|
||||||
* branch into READ would let any worker read every peer lead's mail in full. coordId also takes
|
* any worker or architect read every peer lead's mail in full. coordId also takes priority over
|
||||||
* priority over target when both happen to be present — it addresses a different inbox entirely.
|
* target when both happen to be present — it addresses a different inbox entirely.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void pollingByCoordIdIsACoordReadNeverAPlainRead() {
|
void pollingByCoordIdIsACoordReadNeverAPlainOrTaskRead() {
|
||||||
assertEquals(Authz.Action.COORD_READ, FleetMcp.pollAction(null, "mac-opus"),
|
assertEquals(Authz.Action.COORD_READ, FleetMcp.pollAction(null, "mac-opus"),
|
||||||
"reading held peer mail must not be mapped to the everyone-readable READ action");
|
"reading held peer mail must not be mapped to a widely-readable action");
|
||||||
assertEquals(Authz.Action.COORD_READ, FleetMcp.pollAction(" ", "mac-opus"),
|
assertEquals(Authz.Action.COORD_READ, FleetMcp.pollAction(" ", "mac-opus"),
|
||||||
"a blank target must not fall through to READ/DRAIN when coordId is present");
|
"a blank target must not fall through to READ/DRAIN when coordId is present");
|
||||||
assertEquals(Authz.Action.READ, FleetMcp.pollAction(null, " "),
|
assertEquals(Authz.Action.TASK_READ, FleetMcp.pollAction(null, " "),
|
||||||
"a blank coordId is an absent coordId, same as target/ticket");
|
"a blank coordId is an absent coordId, same as target/ticket");
|
||||||
assertEquals(Authz.Action.COORD_READ, FleetMcp.pollAction("term_b", "mac-opus"),
|
assertEquals(Authz.Action.COORD_READ, FleetMcp.pollAction("term_b", "mac-opus"),
|
||||||
"coordId takes priority over target — this is a different inbox, not a drain");
|
"coordId takes priority over target — this is a different inbox, not a drain");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code fleet_send} is three call shapes behind one tool name, exactly as {@code fleet_poll}
|
||||||
|
* is (fleetd #669 Unit A). {@link FleetMcp#sendAction} picks the action from the arguments, not
|
||||||
|
* the handler, for the same reason {@link FleetMcp#pollAction} does: a test can assert the
|
||||||
|
* mapping the handler actually uses.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void sendMapsToThreeDifferentActionsByItsArguments() {
|
||||||
|
assertEquals(Authz.Action.SEND, FleetMcp.sendAction(null, null),
|
||||||
|
"a plain delivery, with neither coordId nor turnId, is a local SEND");
|
||||||
|
assertEquals(Authz.Action.COORD_SEND, FleetMcp.sendAction("mac-opus", null),
|
||||||
|
"coordId addresses a peer lead over the coordination broker");
|
||||||
|
assertEquals(Authz.Action.ANSWER, FleetMcp.sendAction(null, "turn-1"),
|
||||||
|
"turnId resolves a worker's blocked question");
|
||||||
|
assertEquals(Authz.Action.COORD_SEND, FleetMcp.sendAction("mac-opus", "turn-1"),
|
||||||
|
"coordId takes priority over turnId, mirroring sendToLead's own mutual-exclusion check");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void everyRegisteredToolHasItsHandlerActionPinned() {
|
void everyRegisteredToolHasItsHandlerActionPinned() {
|
||||||
// fleetd #469: this used to scrape FleetMcp.java's tool("…") calls for the registered set —
|
// fleetd #469: this used to scrape FleetMcp.java's tool("…") calls for the registered set —
|
||||||
@@ -344,16 +415,22 @@ class FleetMcpAuthzTest {
|
|||||||
() -> tool + " is registered but has no pinned authorization action"));
|
() -> tool + " is registered but has no pinned authorization action"));
|
||||||
|
|
||||||
assertEquals(Authz.Action.SEND, FleetMcp.toolAction("fleet_send", Map.of()));
|
assertEquals(Authz.Action.SEND, FleetMcp.toolAction("fleet_send", Map.of()));
|
||||||
|
assertEquals(Authz.Action.SEND,
|
||||||
|
FleetMcp.toolAction("fleet_send", Map.of("sessionId", "term_a", "content", "hi")));
|
||||||
|
assertEquals(Authz.Action.COORD_SEND,
|
||||||
|
FleetMcp.toolAction("fleet_send", Map.of("coordId", "mac-opus", "content", "hi")));
|
||||||
|
assertEquals(Authz.Action.ANSWER,
|
||||||
|
FleetMcp.toolAction("fleet_send", Map.of("turnId", "turn-1", "content", "hi")));
|
||||||
assertEquals(Authz.Action.REPLY, FleetMcp.toolAction("fleet_reply", Map.of()));
|
assertEquals(Authz.Action.REPLY, FleetMcp.toolAction("fleet_reply", Map.of()));
|
||||||
assertEquals(Authz.Action.ASK, FleetMcp.toolAction("fleet_ask", Map.of()));
|
assertEquals(Authz.Action.ASK, FleetMcp.toolAction("fleet_ask", Map.of()));
|
||||||
assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_status", Map.of()));
|
assertEquals(Authz.Action.TASK_READ, FleetMcp.toolAction("fleet_status", Map.of()));
|
||||||
assertEquals(Authz.Action.DRAIN, FleetMcp.toolAction("fleet_ack", Map.of()));
|
assertEquals(Authz.Action.DRAIN, FleetMcp.toolAction("fleet_ack", Map.of()));
|
||||||
assertEquals(Authz.Action.SPAWN, FleetMcp.toolAction("fleet_spawn", Map.of()));
|
assertEquals(Authz.Action.SPAWN, FleetMcp.toolAction("fleet_spawn", Map.of()));
|
||||||
assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_list", Map.of()));
|
assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_list", Map.of()));
|
||||||
assertEquals(Authz.Action.STOP, FleetMcp.toolAction("fleet_stop", Map.of()));
|
assertEquals(Authz.Action.STOP, FleetMcp.toolAction("fleet_stop", Map.of()));
|
||||||
assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_profiles", Map.of()));
|
assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_profiles", Map.of()));
|
||||||
assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_whoami", Map.of()));
|
assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_whoami", Map.of()));
|
||||||
assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_poll", Map.of("ticket", "task")));
|
assertEquals(Authz.Action.TASK_READ, FleetMcp.toolAction("fleet_poll", Map.of("ticket", "task")));
|
||||||
assertEquals(Authz.Action.DRAIN, FleetMcp.toolAction("fleet_poll", Map.of("target", "term_b")));
|
assertEquals(Authz.Action.DRAIN, FleetMcp.toolAction("fleet_poll", Map.of("target", "term_b")));
|
||||||
assertEquals(Authz.Action.COORD_READ,
|
assertEquals(Authz.Action.COORD_READ,
|
||||||
FleetMcp.toolAction("fleet_poll", Map.of("coordId", "mac-opus")));
|
FleetMcp.toolAction("fleet_poll", Map.of("coordId", "mac-opus")));
|
||||||
|
|||||||
@@ -1837,6 +1837,32 @@ class FleetMcpTest {
|
|||||||
assertTrue(out.contains("\"sessionId\":\"term_design\""), out);
|
assertTrue(out.contains("\"sessionId\":\"term_design\""), out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collaborator reports its own role and name, never the {@code leader} key a lead gets —
|
||||||
|
* {@code role} already reads {@code "collaborator"}, so a {@code leader} key alongside it
|
||||||
|
* would be self-contradicting. Control: the same call shape fed a named lead must still carry
|
||||||
|
* {@code leader}, so this is not passing because the key stopped being emitted for everyone.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void whoamiReportsACollaboratorWithNoLeaderKeyButALeadStillGetsOne() {
|
||||||
|
FakeHerdr h = new FakeHerdr();
|
||||||
|
SessionManager sessions = sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw"));
|
||||||
|
|
||||||
|
McpSchema.CallToolResult collabRes = FleetMcp.whoami(
|
||||||
|
Principal.collaborator("ops", "term_collab", 700), sessions);
|
||||||
|
assertNotEquals(Boolean.TRUE, collabRes.isError());
|
||||||
|
String collabOut = textOf(collabRes);
|
||||||
|
assertTrue(collabOut.contains("\"role\":\"collaborator\""), collabOut);
|
||||||
|
assertTrue(collabOut.contains("\"collaborator\":\"ops\""), collabOut);
|
||||||
|
assertTrue(collabOut.contains("\"sessionId\":\"term_collab\""), collabOut);
|
||||||
|
assertFalse(collabOut.contains("leader"), collabOut);
|
||||||
|
|
||||||
|
McpSchema.CallToolResult leadRes = FleetMcp.whoami(
|
||||||
|
Principal.leader("opus", "term_lead", 100), sessions);
|
||||||
|
String leadOut = textOf(leadRes);
|
||||||
|
assertTrue(leadOut.contains("\"leader\":\"opus\""), leadOut);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CB-548: an architect SEND delegates as its own pane (recording the per-target delegation) but
|
* CB-548: an architect SEND delegates as its own pane (recording the per-target delegation) but
|
||||||
* must NEVER become the legacy singleton "primary" fallback — the per-target map does not cure
|
* must NEVER become the legacy singleton "primary" fallback — the per-target map does not cure
|
||||||
|
|||||||
@@ -60,13 +60,25 @@ class MessageServiceTest {
|
|||||||
inbox.own(T);
|
inbox.own(T);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A send budget large enough that a test's own setup — {@link #awaitWaiting()} plus whatever
|
||||||
|
* status transitions it drives afterward — can never compete with it for the same clock. A test
|
||||||
|
* that needs {@code send.get(...)}'s own window to be the only timing bound it depends on uses
|
||||||
|
* {@link #sendAsync(String, long)} with this value instead of the default 5000 ms.
|
||||||
|
*/
|
||||||
|
private static final long GENEROUS_SEND_BUDGET_MILLIS = 30_000;
|
||||||
|
|
||||||
/** Run {@code send} on a background thread; the current thread drives the worker's turn. */
|
/** Run {@code send} on a background thread; the current thread drives the worker's turn. */
|
||||||
private CompletableFuture<MessageService.Reply> sendAsync() {
|
private CompletableFuture<MessageService.Reply> sendAsync() {
|
||||||
return sendAsync("do the task");
|
return sendAsync("do the task");
|
||||||
}
|
}
|
||||||
|
|
||||||
private CompletableFuture<MessageService.Reply> sendAsync(String content) {
|
private CompletableFuture<MessageService.Reply> sendAsync(String content) {
|
||||||
return CompletableFuture.supplyAsync(() -> messages.send(T, content, 5000));
|
return sendAsync(content, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<MessageService.Reply> sendAsync(String content, long timeoutMillis) {
|
||||||
|
return CompletableFuture.supplyAsync(() -> messages.send(T, content, timeoutMillis));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void awaitWaiting() throws InterruptedException {
|
private void awaitWaiting() throws InterruptedException {
|
||||||
@@ -80,7 +92,7 @@ class MessageServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void completionFallbackResolvesATurnThatNeverCalledFleetReply() throws Exception {
|
void completionFallbackResolvesATurnThatNeverCalledFleetReply() throws Exception {
|
||||||
CompletableFuture<MessageService.Reply> send = sendAsync();
|
CompletableFuture<MessageService.Reply> send = sendAsync("do the task", GENEROUS_SEND_BUDGET_MILLIS);
|
||||||
awaitWaiting();
|
awaitWaiting();
|
||||||
|
|
||||||
herdr.readText("$ prompt"); // pre-turn pane: no answer yet (baseline reference)
|
herdr.readText("$ prompt"); // pre-turn pane: no answer yet (baseline reference)
|
||||||
@@ -96,6 +108,32 @@ class MessageServiceTest {
|
|||||||
assertTrue(reply.completed(), "a scraped completion still counts as completed");
|
assertTrue(reply.completed(), "a scraped completion still counts as completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins {@link #GENEROUS_SEND_BUDGET_MILLIS} as the budget {@link
|
||||||
|
* #completionFallbackResolvesATurnThatNeverCalledFleetReply} depends on. A 5500 ms delay between
|
||||||
|
* {@link #awaitWaiting()} and the status transitions that drive completion stands in for a loaded
|
||||||
|
* machine's setup overhead — comfortably past the 5000 ms budget this send no longer uses, and
|
||||||
|
* still well inside this method's own 30 000 ms budget. The only clock this test depends on is
|
||||||
|
* {@code send.get}'s own 10 s window.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void completionFallbackSurvivesASlowHarnessBecauseItsSendBudgetIsNotTheBindingClock() throws Exception {
|
||||||
|
CompletableFuture<MessageService.Reply> send = sendAsync("do the task", GENEROUS_SEND_BUDGET_MILLIS);
|
||||||
|
awaitWaiting();
|
||||||
|
|
||||||
|
Thread.sleep(5500);
|
||||||
|
|
||||||
|
herdr.readText("$ prompt");
|
||||||
|
injector.onStatus(T, AgentStatus.IDLE);
|
||||||
|
injector.onStatus(T, AgentStatus.WORKING);
|
||||||
|
herdr.readText("BUILD GREEN: 391 files");
|
||||||
|
injector.onStatus(T, AgentStatus.IDLE);
|
||||||
|
|
||||||
|
MessageService.Reply reply = send.get(10, TimeUnit.SECONDS);
|
||||||
|
assertEquals(MessageService.Outcome.COMPLETED_UNREPLIED, reply.outcome(),
|
||||||
|
"a slow harness must not be mistaken for a timed-out delivery");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void completionFallbackReplacesAnEchoedInjectedBriefWithNoReportOutcome() throws Exception {
|
void completionFallbackReplacesAnEchoedInjectedBriefWithNoReportOutcome() throws Exception {
|
||||||
String brief = "Implement the requested change. ".repeat(20);
|
String brief = "Implement the requested change. ".repeat(20);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package dev.ltms.fleet.rest;
|
package dev.ltms.fleet.rest;
|
||||||
|
|
||||||
|
import ch.qos.logback.classic.Level;
|
||||||
|
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import dev.ltms.fleet.auth.CallerResolver;
|
import dev.ltms.fleet.auth.CallerResolver;
|
||||||
import dev.ltms.fleet.auth.Authz;
|
import dev.ltms.fleet.auth.Authz;
|
||||||
import dev.ltms.fleet.auth.MemberRegistry;
|
import dev.ltms.fleet.auth.MemberRegistry;
|
||||||
|
import dev.ltms.fleet.auth.Principal;
|
||||||
import dev.ltms.fleet.config.FleetConfig;
|
import dev.ltms.fleet.config.FleetConfig;
|
||||||
import dev.ltms.fleet.guard.SubscriptionGuard;
|
import dev.ltms.fleet.guard.SubscriptionGuard;
|
||||||
import dev.ltms.fleet.herdr.AgentControl;
|
import dev.ltms.fleet.herdr.AgentControl;
|
||||||
@@ -18,6 +22,7 @@ import dev.ltms.fleet.msg.Rendezvous;
|
|||||||
import dev.ltms.fleet.session.FakeWorktrees;
|
import dev.ltms.fleet.session.FakeWorktrees;
|
||||||
import dev.ltms.fleet.session.SessionManager;
|
import dev.ltms.fleet.session.SessionManager;
|
||||||
import dev.ltms.fleet.member.ClaudeCodeLauncher;
|
import dev.ltms.fleet.member.ClaudeCodeLauncher;
|
||||||
|
import dev.ltms.fleet.testing.CapturedLog;
|
||||||
import io.javalin.Javalin;
|
import io.javalin.Javalin;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -28,10 +33,13 @@ import java.net.http.HttpRequest;
|
|||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.function.Predicate;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -122,12 +130,60 @@ class FleetAppAuthTest {
|
|||||||
assertEquals(Authz.Action.DRAIN, FleetApp.routeAction("GET /sessions/{id}/replies"));
|
assertEquals(Authz.Action.DRAIN, FleetApp.routeAction("GET /sessions/{id}/replies"));
|
||||||
assertEquals(Authz.Action.ASK, FleetApp.routeAction("POST /sessions/{id}/ask"));
|
assertEquals(Authz.Action.ASK, FleetApp.routeAction("POST /sessions/{id}/ask"));
|
||||||
for (String route : Set.of("GET /sessions", "GET /agents", "GET /members", "GET /profiles",
|
for (String route : Set.of("GET /sessions", "GET /agents", "GET /members", "GET /profiles",
|
||||||
"GET /member-credentials", "GET /sessions/{id}/status", "GET /tasks/{ticket}")) {
|
"GET /member-credentials")) {
|
||||||
assertEquals(Authz.Action.READ, FleetApp.routeAction(route), route);
|
assertEquals(Authz.Action.READ, FleetApp.routeAction(route), route);
|
||||||
}
|
}
|
||||||
|
for (String route : Set.of("GET /sessions/{id}/status", "GET /tasks/{ticket}")) {
|
||||||
|
assertEquals(Authz.Action.TASK_READ, FleetApp.routeAction(route), route);
|
||||||
|
}
|
||||||
assertThrows(IllegalArgumentException.class, () -> FleetApp.routeAction("GET /healthz"));
|
assertThrows(IllegalArgumentException.class, () -> FleetApp.routeAction("GET /healthz"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669 Unit A: {@code POST /sessions/{id}/message} is two call shapes behind one route,
|
||||||
|
* mirroring {@code fleet_send}'s MCP-side split into {@link Authz.Action#SEND} and {@link
|
||||||
|
* Authz.Action#ANSWER} ({@code FleetMcp#sendAction}). The route never carries a {@code coordId}
|
||||||
|
* shape — that peer-lead route is MCP-only — so only these two apply here.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void theMessageRouteIsASendWithNoTurnIdAndAnAnswerWithOne() {
|
||||||
|
assertEquals(Authz.Action.SEND, FleetApp.routeAction("POST /sessions/{id}/message", null));
|
||||||
|
assertEquals(Authz.Action.SEND, FleetApp.routeAction("POST /sessions/{id}/message", " "));
|
||||||
|
assertEquals(Authz.Action.ANSWER, FleetApp.routeAction("POST /sessions/{id}/message", "turn-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #689: {@code answerGatePasses} is the second, conditional gate behind {@code
|
||||||
|
* sendMessage}'s coarse {@link Authz.Action#SEND} check. With the {@code ANSWER} grant denied,
|
||||||
|
* a {@code turnId}-bearing request is refused while a plain one still passes — and the denied
|
||||||
|
* permit is queried only for the {@code turnId} case, never for the plain one, which is what
|
||||||
|
* proves this is a genuinely separate, conditional check rather than the {@code SEND} check
|
||||||
|
* renamed or an unconditional call whose result is ignored. Flipping only the {@code ANSWER}
|
||||||
|
* grant to allowed then flips only the {@code turnId} shape's outcome.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void answerGatePassesOnlyWhenTurnIdAbsentOrAnswerGranted() {
|
||||||
|
List<Authz.Action> queried = new ArrayList<>();
|
||||||
|
Predicate<Authz.Action> denyAnswer = action -> {
|
||||||
|
queried.add(action);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
assertFalse(FleetApp.answerGatePasses("turn-1", denyAnswer),
|
||||||
|
"ANSWER denied ⇒ the turnId shape is refused");
|
||||||
|
assertEquals(List.of(Authz.Action.ANSWER), queried,
|
||||||
|
"the ANSWER grant, specifically, must be the one consulted");
|
||||||
|
|
||||||
|
queried.clear();
|
||||||
|
assertTrue(FleetApp.answerGatePasses(null, denyAnswer),
|
||||||
|
"no turnId ⇒ the plain shape passes even though ANSWER is denied");
|
||||||
|
assertTrue(FleetApp.answerGatePasses(" ", denyAnswer), "a blank turnId is treated as absent");
|
||||||
|
assertEquals(List.of(), queried, "the plain shape must never consult the permit at all");
|
||||||
|
|
||||||
|
assertTrue(FleetApp.answerGatePasses("turn-1", action -> true),
|
||||||
|
"flipping only the ANSWER grant to allowed flips only the turnId shape's outcome");
|
||||||
|
}
|
||||||
|
|
||||||
private static Set<String> routesTheServerRegisters() {
|
private static Set<String> routesTheServerRegisters() {
|
||||||
try {
|
try {
|
||||||
String source = Files.readString(REST_SOURCE).lines()
|
String source = Files.readString(REST_SOURCE).lines()
|
||||||
@@ -147,6 +203,18 @@ class FleetAppAuthTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code permitsFor} is the exact decision {@link FleetApp#allow} makes, passing the real
|
||||||
|
* production classifier rather than a test-supplied one — no terminal is recognised as a
|
||||||
|
* configured lead or collaborator, so a collaborator's SEND is refused through the REST gate.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aCollaboratorMayNotSendOverRestWithTheRealProductionClassifier() {
|
||||||
|
Principal collaborator = Principal.collaborator("ops", "term_collab", 700);
|
||||||
|
assertFalse(FleetApp.permitsFor(collaborator, Authz.Action.SEND, "term_lead"),
|
||||||
|
"no terminal is recognised as a lead or collaborator yet");
|
||||||
|
}
|
||||||
|
|
||||||
// --- loopback-trust: the caller is the primary -------------------------------------------
|
// --- loopback-trust: the caller is the primary -------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -192,6 +260,107 @@ class FleetAppAuthTest {
|
|||||||
"draining an inbox is the primary's collection step");
|
"draining an inbox is the primary's collection step");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #669 Unit A: the {@code turnId} shape of {@code POST /sessions/{id}/message} maps to
|
||||||
|
* {@link Authz.Action#ANSWER}, not the plain {@link Authz.Action#SEND} the test above drives —
|
||||||
|
* a worker must stay refused on this shape too, exactly as it was refused on the one undivided
|
||||||
|
* action before the split.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aWorkerMayNotAnswerAnotherSessionsBlockedQuestionOverRest() throws Exception {
|
||||||
|
int port = start(FakeHerdr.WORKER_PID, false, null);
|
||||||
|
|
||||||
|
assertEquals(403, send(port, "POST", "/sessions/term_b/message",
|
||||||
|
"{\"turnId\":\"turn-1\",\"content\":\"hi\"}", null).statusCode(),
|
||||||
|
"resolving another session's blocked question would be a worker escalating too");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #689: a caller refused the coarse {@link Authz.Action#SEND} grant is refused on
|
||||||
|
* {@code SEND} specifically, even on the {@code turnId}-bearing shape that otherwise raises
|
||||||
|
* the check to {@link Authz.Action#ANSWER} — proving {@code turnId} was never read from the
|
||||||
|
* body before the refusal (reading it would have changed which action is named in the 403).
|
||||||
|
* The same caller refused with no body at all gets the identical detail, which could not hold
|
||||||
|
* if the decision depended on anything read from the body. Control: a caller who IS granted
|
||||||
|
* reaches past the gate and the body is used normally.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aDeniedCallerIsRefusedOnSendEvenWithATurnIdBodyAndNeverReadsTheBody() throws Exception {
|
||||||
|
int workerPort = start(FakeHerdr.WORKER_PID, false, null); // denied: not primary/architect
|
||||||
|
|
||||||
|
HttpResponse<String> withTurnId = send(workerPort, "POST", "/sessions/term_b/message",
|
||||||
|
"{\"turnId\":\"turn-1\",\"content\":\"hi\"}", null);
|
||||||
|
assertEquals(403, withTurnId.statusCode());
|
||||||
|
assertTrue(withTurnId.body().contains("may not SEND"),
|
||||||
|
"the SEND check must be the one that fired, not ANSWER — ANSWER would only be "
|
||||||
|
+ "reachable by having already read turnId out of the body");
|
||||||
|
|
||||||
|
HttpResponse<String> noBody = send(workerPort, "POST", "/sessions/term_b/message", null, null);
|
||||||
|
assertEquals(403, noBody.statusCode());
|
||||||
|
assertTrue(noBody.body().contains("may not SEND"),
|
||||||
|
"refused identically with no body at all — the refusal cannot depend on body content");
|
||||||
|
|
||||||
|
// Control: a primary IS granted SEND, so the same turnId body is read and acted on —
|
||||||
|
// reaching messages.answer, which reports this unknown turnId as a stale one.
|
||||||
|
int primaryPort = start(999_999, false, null);
|
||||||
|
HttpResponse<String> granted = send(primaryPort, "POST", "/sessions/term_b/message",
|
||||||
|
"{\"turnId\":\"turn-1\",\"content\":\"hi\"}", null);
|
||||||
|
assertEquals(409, granted.statusCode());
|
||||||
|
assertTrue(granted.body().contains("stale_turn"), "a granted caller's body IS read and acted on");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #689 (ticket comment 18353): the only place {@code sendMessage}'s call to {@code
|
||||||
|
* answerGatePasses} is observable is the audit trail — {@code allow()} logs an {@code
|
||||||
|
* "allowed"} entry for every granted action except {@code READ}/{@code METRICS}/{@code
|
||||||
|
* TASK_READ}, and {@code ANSWER} is none of those. A granted {@code turnId} request must
|
||||||
|
* therefore log both a {@code SEND} and an {@code ANSWER} entry; a granted plain request must
|
||||||
|
* log {@code SEND} alone. A unit test of the extracted helper pins the helper; this pins the
|
||||||
|
* call site — deleting the {@code answerGatePasses} call from {@code sendMessage} leaves the
|
||||||
|
* helper's own test green but turns this one red.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void aGrantedTurnIdRequestAuditsBothSendAndAnswerButAPlainRequestAuditsSendAlone() throws Exception {
|
||||||
|
int port = start(999_999, false, null); // primary: granted both SEND and ANSWER
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
try (CapturedLog audit = CapturedLog.at("audit", Level.INFO)) {
|
||||||
|
send(port, "POST", "/sessions/term_b/message",
|
||||||
|
"{\"turnId\":\"turn-1\",\"content\":\"hi\"}", null);
|
||||||
|
|
||||||
|
List<String> allowed = allowedActions(audit, mapper);
|
||||||
|
assertTrue(allowed.contains("SEND"),
|
||||||
|
"a turnId request must still clear the coarse SEND grant first");
|
||||||
|
assertTrue(allowed.contains("ANSWER"),
|
||||||
|
"a turnId request must ALSO clear the ANSWER grant — this is the call site itself");
|
||||||
|
}
|
||||||
|
|
||||||
|
try (CapturedLog audit = CapturedLog.at("audit", Level.INFO)) {
|
||||||
|
send(port, "POST", "/sessions/term_b/message",
|
||||||
|
"{\"content\":\"hi\",\"timeoutMs\":50}", null);
|
||||||
|
|
||||||
|
List<String> allowed = allowedActions(audit, mapper);
|
||||||
|
assertEquals(List.of("SEND"), allowed,
|
||||||
|
"a plain request must log SEND and nothing else — ANSWER is conditional on "
|
||||||
|
+ "turnId, not something every request happens to log");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> allowedActions(CapturedLog audit, ObjectMapper mapper) {
|
||||||
|
return audit.events().stream()
|
||||||
|
.map(ILoggingEvent::getFormattedMessage)
|
||||||
|
.map(line -> {
|
||||||
|
try {
|
||||||
|
return mapper.readTree(line);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new AssertionError("audit line is not valid JSON: " + line, e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(n -> "allowed".equals(n.path("outcome").asText()))
|
||||||
|
.map(n -> n.path("action").asText())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
// --- token mode ---------------------------------------------------------------------------
|
// --- token mode ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -675,6 +675,26 @@ class FleetAppTest {
|
|||||||
assertEquals(400, postMessage(port, "{}").statusCode());
|
assertEquals(400, postMessage(port, "{}").statusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fleetd #689: a body that fails to parse is rejected with 400 before {@code turnId} is ever
|
||||||
|
* read from it, so it reaches neither {@code messages.answer} (which needs a {@code turnId})
|
||||||
|
* nor {@code messages.send} — confirmed here for {@code send} by the fake agent's idle status,
|
||||||
|
* which would otherwise make an immediate {@code agent.prompt} delivery observable.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void malformedBodyReturns400AndNeverReachesSendOrAnswer() throws Exception {
|
||||||
|
FakeHerdr herdr = new FakeHerdr().agentStatus("idle"); // idle ⇒ send would deliver right away if reached
|
||||||
|
int port = start(herdr, "http://gx00.gw:8000", Set.of("gx00.gw"));
|
||||||
|
|
||||||
|
HttpResponse<String> res = postMessage(port, "not json at all");
|
||||||
|
assertEquals(400, res.statusCode());
|
||||||
|
JsonNode err = mapper.readTree(res.body());
|
||||||
|
assertEquals("bad_request", err.get("error").asText());
|
||||||
|
assertEquals("body must be JSON", err.get("detail").asText());
|
||||||
|
assertFalse(herdr.called("agent.prompt"),
|
||||||
|
"a malformed body must never reach messages.send's delivery");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void sessionStatusReportsLiveAgentStatus() throws Exception {
|
void sessionStatusReportsLiveAgentStatus() throws Exception {
|
||||||
FakeHerdr herdr = new FakeHerdr().agentStatus("blocked");
|
FakeHerdr herdr = new FakeHerdr().agentStatus("blocked");
|
||||||
|
|||||||
+22
-5
@@ -213,6 +213,17 @@ map_masked_lines() {
|
|||||||
done < "$file"
|
done < "$file"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Masks every `scheme://user:pass@host` userinfo on one line of text, replacing just that
|
||||||
|
# userinfo with `<redacted>` and leaving the rest of the line untouched, byte for byte. The
|
||||||
|
# pattern stops at the first `/`, whitespace, or `@` reached after `://` — a URI's userinfo
|
||||||
|
# component cannot contain any of those three characters — so a URI with no userinfo, followed
|
||||||
|
# later on the same line by an unrelated `@`, never matches. The `g` flag matters: a line can
|
||||||
|
# carry more than one URI. Shared by every caller that prints a line which may hold a
|
||||||
|
# credentialed URI, so the bound lives in exactly one place.
|
||||||
|
mask_url_userinfo() {
|
||||||
|
printf '%s\n' "$1" | sed -E 's#://[^@/[:space:]]*@#://<redacted>@#g'
|
||||||
|
}
|
||||||
|
|
||||||
redact() {
|
redact() {
|
||||||
local old_file="$1" new_file="$2"
|
local old_file="$1" new_file="$2"
|
||||||
local line prefix content indent lead key old_line=0 new_line=0 in_hunk=0
|
local line prefix content indent lead key old_line=0 new_line=0 in_hunk=0
|
||||||
@@ -270,7 +281,7 @@ redact() {
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
printf '%s\n' "$line" | sed -E 's#://[^@]*@#://<redacted>@#g'
|
mask_url_userinfo "$line"
|
||||||
done
|
done
|
||||||
[ "$saved_nocasematch" = 1 ] || shopt -u nocasematch
|
[ "$saved_nocasematch" = 1 ] || shopt -u nocasematch
|
||||||
}
|
}
|
||||||
@@ -580,6 +591,12 @@ install_candidate() {
|
|||||||
|
|
||||||
# -------------------------------------------------------------------------------- the report path
|
# -------------------------------------------------------------------------------- the report path
|
||||||
#
|
#
|
||||||
|
# Masks basic-auth userinfo (scheme://user:pass@host) in a daemon verdict line before it reaches
|
||||||
|
# the terminal.
|
||||||
|
mask_verdict_userinfo() {
|
||||||
|
mask_url_userinfo "$1"
|
||||||
|
}
|
||||||
|
|
||||||
# Prints the literal command the operator (or a test) can run to restore the backup by hand — the
|
# Prints the literal command the operator (or a test) can run to restore the backup by hand — the
|
||||||
# absolute path to THIS script plus the overrides actually in force, so it works from any cwd.
|
# absolute path to THIS script plus the overrides actually in force, so it works from any cwd.
|
||||||
restore_command_line() {
|
restore_command_line() {
|
||||||
@@ -599,8 +616,8 @@ restore_and_confirm() {
|
|||||||
ok "restored from $backup"
|
ok "restored from $backup"
|
||||||
if wait_for_verdict "$LOG" "$mark2" "$WAIT_SECONDS"; then
|
if wait_for_verdict "$LOG" "$mark2" "$WAIT_SECONDS"; then
|
||||||
case "$VERDICT_KIND" in
|
case "$VERDICT_KIND" in
|
||||||
refused) warn "the RESTORE was also refused by the daemon: $VERDICT_LINE" ;;
|
refused) warn "the RESTORE was also refused by the daemon: $(mask_verdict_userinfo "$VERDICT_LINE")" ;;
|
||||||
*) ok "restore confirmed: $VERDICT_LINE" ;;
|
*) ok "restore confirmed: $(mask_verdict_userinfo "$VERDICT_LINE")" ;;
|
||||||
esac
|
esac
|
||||||
else
|
else
|
||||||
warn "the restore is on disk, but no confirming verdict line appeared within ${WAIT_SECONDS}s"
|
warn "the restore is on disk, but no confirming verdict line appeared within ${WAIT_SECONDS}s"
|
||||||
@@ -616,7 +633,7 @@ report_outcome() {
|
|||||||
|
|
||||||
say "waiting for the daemon's verdict (up to ${WAIT_SECONDS}s)"
|
say "waiting for the daemon's verdict (up to ${WAIT_SECONDS}s)"
|
||||||
if wait_for_verdict "$LOG" "$mark" "$WAIT_SECONDS"; then
|
if wait_for_verdict "$LOG" "$mark" "$WAIT_SECONDS"; then
|
||||||
kind="$VERDICT_KIND"; line="$VERDICT_LINE"
|
kind="$VERDICT_KIND"; line="$(mask_verdict_userinfo "$VERDICT_LINE")"
|
||||||
else
|
else
|
||||||
kind="none"
|
kind="none"
|
||||||
fi
|
fi
|
||||||
@@ -673,7 +690,7 @@ check_mode() {
|
|||||||
local verdict
|
local verdict
|
||||||
verdict="$(last_verdict_line "$LOG")"
|
verdict="$(last_verdict_line "$LOG")"
|
||||||
if [ -n "$verdict" ]; then
|
if [ -n "$verdict" ]; then
|
||||||
ok "last verdict in log: $verdict"
|
ok "last verdict in log: $(mask_verdict_userinfo "$verdict")"
|
||||||
else
|
else
|
||||||
warn "no reload verdict line found in $LOG"
|
warn "no reload verdict line found in $LOG"
|
||||||
fi
|
fi
|
||||||
|
|||||||
+131
-66
@@ -71,20 +71,22 @@ set -euo pipefail
|
|||||||
|
|
||||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
MODULE="$REPO/fleetd"
|
MODULE="$REPO/fleetd"
|
||||||
JAR="$MODULE/target/fleetd.jar"
|
# fleetd #664: the runtime path and Maven's output path are no longer the same file. Maven's
|
||||||
# fleetd #493: never build into the path a running process holds. The build writes here first
|
# shade plugin (finalName=fleetd) always lands a fresh build at target/fleetd.jar — that is
|
||||||
# (Maven's shade plugin has finalName=fleetd, so `clean install` still lands its output at
|
# Maven's own output directory and this script does not change it — but the daemon is launched
|
||||||
# target/fleetd.jar — that part is unchanged and out of this script's control), but this script
|
# from $JAR instead, outside target/ entirely. That split is the whole fix: neither `mvn install`
|
||||||
# now moves it out to JAR_STAGED immediately, and only swaps it back to JAR (a plain `mv`, so a
|
# nor `mvn clean` can ever reach the file a running daemon holds open, because that file no
|
||||||
# rename, never a byte-by-byte overwrite) after the OLD daemon has been confirmed exited. See
|
# longer lives under target/ at all. See swap_if_built/swap_staged_jar below for the one `mv`
|
||||||
# stage_built_jar/swap_staged_jar below.
|
# that moves a build from one path to the other, and only after the old daemon is confirmed gone.
|
||||||
JAR_STAGED="$MODULE/target/fleetd-new.jar"
|
BUILD_JAR="$MODULE/target/fleetd.jar"
|
||||||
|
JAR="$MODULE/run/fleetd.jar"
|
||||||
OUT="$MODULE/fleetd.out"
|
OUT="$MODULE/fleetd.out"
|
||||||
# Matches BOTH the absolute form and the relative `java -jar target/fleetd.jar` a hand-start
|
# Matches a fleetd daemon's command line wherever its jar sits — absolute or relative, under
|
||||||
# produces from inside fleetd/. Anchoring on the absolute path alone was a real bug: the daemon
|
# run/, under target/, or anywhere else a build or a hand-start might point it. Detecting a
|
||||||
# restarted correctly and the script still reported "no process appeared", because it launched with
|
# daemon this script did not start, including one running from a jar outside $JAR's own
|
||||||
# a relative path and then looked for an absolute one.
|
# directory, is this pattern's whole job; running_pid()'s `comm = java` allowlist below is what
|
||||||
PATTERN='target/fleetd.jar'
|
# keeps that breadth from counting a shell that merely types the pattern as literal text.
|
||||||
|
PATTERN='fleetd.jar'
|
||||||
HEALTH='http://127.0.0.1:8765/healthz'
|
HEALTH='http://127.0.0.1:8765/healthz'
|
||||||
STOP_WAIT=30 # seconds to wait for a clean exit before reporting failure
|
STOP_WAIT=30 # seconds to wait for a clean exit before reporting failure
|
||||||
HEALTH_WAIT=60 # seconds to wait for /healthz to answer after start — fleetd #603: also the pid-
|
HEALTH_WAIT=60 # seconds to wait for /healthz to answer after start — fleetd #603: also the pid-
|
||||||
@@ -169,10 +171,10 @@ hash256() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Reports the hash of $JAR by default, or of whatever path is passed — used to report the STAGED
|
# Reports the hash of $JAR by default, or of whatever path is passed — used to report the BUILT
|
||||||
# jar right after a build (before it has been swapped in) without ever changing what a bare
|
# jar at $BUILD_JAR (before it has been swapped in) without ever changing what a bare `jar_id`
|
||||||
# `jar_id` (no args) means: the live path, $JAR. --check and the final "pid ..., jar ..." line
|
# (no args) means: the live path, $JAR. --check and the final "pid ..., jar ..." line both call
|
||||||
# both call it with no args on purpose, so neither can ever be fooled by a leftover staged file.
|
# it with no args on purpose, so neither can ever be fooled by a leftover build output.
|
||||||
# fleetd #550 — THREE distinct answers now, not two: `[ -f "$f" ]` already separates "the jar is
|
# fleetd #550 — THREE distinct answers now, not two: `[ -f "$f" ]` already separates "the jar is
|
||||||
# not there" (-> "absent") from "the jar is there"; for the second case, hash256 itself separates
|
# not there" (-> "absent") from "the jar is there"; for the second case, hash256 itself separates
|
||||||
# "hashed it" (a 12-char hex string) from "could not hash it" (-> "unhashable", when no hasher is
|
# "hashed it" (a 12-char hex string) from "could not hash it" (-> "unhashable", when no hasher is
|
||||||
@@ -180,15 +182,35 @@ hash256() {
|
|||||||
# was the whole defect this ticket fixes.
|
# was the whole defect this ticket fixes.
|
||||||
jar_id() { local f="${1:-$JAR}"; [ -f "$f" ] && hash256 "$f" || echo "absent"; }
|
jar_id() { local f="${1:-$JAR}"; [ -f "$f" ] && hash256 "$f" || echo "absent"; }
|
||||||
|
|
||||||
|
# fleetd #664 — under the old layout $JAR and the build output were the same file, so "jar on
|
||||||
|
# disk" was one fact. Now they are two: $BUILD_JAR (target/fleetd.jar, whatever Maven last wrote,
|
||||||
|
# by this script or by a bare `mvn install` run by hand) and $JAR (run/fleetd.jar, whatever the
|
||||||
|
# daemon actually has open). Printing one label for both was the trap this ticket exists to close
|
||||||
|
# — during the incident it would have shown the NEW jar's hash while the JVM ran the OLD one.
|
||||||
|
# Pure (reads jar_id/date, never mutates), so a test can call it directly without reaching the
|
||||||
|
# main flow — the same shape swap_if_built/drain_gate_refusal already use.
|
||||||
|
report_jar_state() {
|
||||||
|
local built_hash running_hash built_mtime running_mtime
|
||||||
|
built_hash="$(jar_id "$BUILD_JAR")"
|
||||||
|
running_hash="$(jar_id "$JAR")"
|
||||||
|
built_mtime="$([ -f "$BUILD_JAR" ] && date -r "$BUILD_JAR" '+%Y-%m-%d %H:%M:%S' || echo 'none')"
|
||||||
|
running_mtime="$([ -f "$JAR" ] && date -r "$JAR" '+%Y-%m-%d %H:%M:%S' || echo 'none')"
|
||||||
|
ok "built jar (target/fleetd.jar): $built_hash ($built_mtime)"
|
||||||
|
ok "running jar (run/fleetd.jar): $running_hash ($running_mtime)"
|
||||||
|
if [ "$built_hash" != "absent" ] && [ "$running_hash" != "absent" ] && [ "$built_hash" != "$running_hash" ]; then
|
||||||
|
warn "built jar and running jar differ — target/fleetd.jar was rebuilt since the running daemon last started and is not yet live"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# fleetd #593 — `pgrep -f "$PATTERN"` matches ANY process whose full command line CONTAINS the
|
# fleetd #593 — `pgrep -f "$PATTERN"` matches ANY process whose full command line CONTAINS the
|
||||||
# pattern text, and that is not the same thing as "is the daemon". A shell that merely embeds the
|
# pattern text, and that is not the same thing as "is the daemon". A shell that merely embeds the
|
||||||
# pattern as literal text — a human typing this exact investigation by hand, an ssh-shaped
|
# pattern as literal text — a human typing this exact investigation by hand, an ssh-shaped
|
||||||
# `sh -c '...; ...'`, a pipeline, or any other non-exec'ing shell that never replaced itself with
|
# `sh -c '...; ...'`, a pipeline, or any other non-exec'ing shell that never replaced itself with
|
||||||
# the pattern-holding command — still shows up in that match, and it is the INSTRUMENT, not the
|
# the pattern-holding command — still shows up in that match, and it is the INSTRUMENT, not the
|
||||||
# daemon. Measured live on this Mac: `sh -c 'echo "target/fleetd.jar" >/dev/null; sleep 30' &`
|
# daemon. Measured live on this Mac: `sh -c 'echo "run/fleetd.jar" >/dev/null; sleep 30' &`
|
||||||
# leaves a real `sh` process alive (it forks for the `sleep`, it does not exec into it) whose own
|
# leaves a real `sh` process alive (it forks for the `sleep`, it does not exec into it) whose own
|
||||||
# `ps -o args` is `sh -c echo "target/fleetd.jar" >/dev/null; sleep 30` — `pgrep -f "$PATTERN"`
|
# `ps -o args` is `sh -c echo "run/fleetd.jar" >/dev/null; sleep 30` — `pgrep -f "$PATTERN"`
|
||||||
# matches that line right alongside the real `java -jar target/fleetd.jar` process. `pgrep -c`
|
# matches that line right alongside the real `java -jar run/fleetd.jar` process. `pgrep -c`
|
||||||
# (an in-one-call count) does not exist on BSD/macOS at all, so this cannot be fixed by switching
|
# (an in-one-call count) does not exist on BSD/macOS at all, so this cannot be fixed by switching
|
||||||
# pgrep flags — it has to filter what pgrep already found, after the fact, in a way that still
|
# pgrep flags — it has to filter what pgrep already found, after the fact, in a way that still
|
||||||
# runs on BSD.
|
# runs on BSD.
|
||||||
@@ -210,7 +232,7 @@ jar_id() { local f="${1:-$JAR}"; [ -f "$f" ] && hash256 "$f" || echo "absent"; }
|
|||||||
# launched as `java -jar ...` — a native image, a renamed launcher — `running_pid()` silently
|
# launched as `java -jar ...` — a native image, a renamed launcher — `running_pid()` silently
|
||||||
# returns nothing and `assert_single_daemon` stops noticing a second daemon at all. For a guard,
|
# returns nothing and `assert_single_daemon` stops noticing a second daemon at all. For a guard,
|
||||||
# that false-negative direction is the worse one to be wrong in. This is not a new assumption,
|
# that false-negative direction is the worse one to be wrong in. This is not a new assumption,
|
||||||
# though: `PATTERN='target/fleetd.jar'` two lines up already assumes the daemon is a jar, which
|
# though: `PATTERN='fleetd.jar'` two lines up already assumes the daemon is a jar, which
|
||||||
# is only ever run by `java`. If that launch method changes, `PATTERN` stops matching anything
|
# is only ever run by `java`. If that launch method changes, `PATTERN` stops matching anything
|
||||||
# before this allowlist would ever get the chance to be wrong — the allowlist rides on the same
|
# before this allowlist would ever get the chance to be wrong — the allowlist rides on the same
|
||||||
# assumption that is already load-bearing, it does not add a new one. Whoever changes the launch
|
# assumption that is already load-bearing, it does not add a new one. Whoever changes the launch
|
||||||
@@ -229,16 +251,12 @@ running_pid() {
|
|||||||
printf '%s' "$out"
|
printf '%s' "$out"
|
||||||
}
|
}
|
||||||
|
|
||||||
# fleetd #493 — three small, independently testable pieces of "never build into the path a
|
# fleetd #493/#664 — the independently testable pieces of "never build into the path a running
|
||||||
# running process holds":
|
# process holds":
|
||||||
#
|
#
|
||||||
# stage_built_jar moves the jar Maven just produced OUT of the live path and onto the staging
|
# require_no_build_jar the --no-build path never builds anything: it must find a jar already
|
||||||
# path, immediately after a successful build. Dies (leaving the OLD daemon
|
# sitting at the live path ($JAR, under run/) from an earlier successful run,
|
||||||
# untouched — this runs before the stop step) if Maven reported success but
|
# and die with the same truthful message this script has always used if not.
|
||||||
# left no jar behind, or if the move itself fails.
|
|
||||||
# require_no_build_jar the --no-build path never builds or stages anything: it must find a
|
|
||||||
# jar already sitting at the live path from an earlier successful run, and
|
|
||||||
# die with the same truthful message this script has always used if not.
|
|
||||||
# wait_for_daemon_exit polls running_pid() for up to $1 seconds and reports whether the OLD
|
# wait_for_daemon_exit polls running_pid() for up to $1 seconds and reports whether the OLD
|
||||||
# daemon actually exited — extracted to its own function so the main flow
|
# daemon actually exited — extracted to its own function so the main flow
|
||||||
# can be relied on to call swap_staged_jar only AFTER this returns success,
|
# can be relied on to call swap_staged_jar only AFTER this returns success,
|
||||||
@@ -248,14 +266,10 @@ running_pid() {
|
|||||||
# so this is never a write into a path a running process holds — by the time
|
# so this is never a write into a path a running process holds — by the time
|
||||||
# it runs, nothing holds that path anymore. If it fails, the caller must not
|
# it runs, nothing holds that path anymore. If it fails, the caller must not
|
||||||
# start a new daemon: die() below already refuses that by exiting the script.
|
# start a new daemon: die() below already refuses that by exiting the script.
|
||||||
stage_built_jar() {
|
# fleetd #664: the "staged" jar swap_if_built passes in is now $BUILD_JAR
|
||||||
[ -f "$JAR" ] || die "build succeeded but produced no jar at $JAR — cannot stage it for restart.
|
# itself (target/fleetd.jar, Maven's own output) — a build no longer needs to
|
||||||
The running daemon was NOT touched."
|
# be moved off the live path right after compiling, because target/ was never
|
||||||
mv -f "$JAR" "$JAR_STAGED" \
|
# the live path to begin with.
|
||||||
|| die "could not move the freshly built jar from $JAR to the staging path $JAR_STAGED.
|
|
||||||
The running daemon was NOT touched."
|
|
||||||
}
|
|
||||||
|
|
||||||
require_no_build_jar() {
|
require_no_build_jar() {
|
||||||
[ -f "$JAR" ] || die "no jar at $JAR — run without --no-build"
|
[ -f "$JAR" ] || die "no jar at $JAR — run without --no-build"
|
||||||
}
|
}
|
||||||
@@ -282,8 +296,7 @@ swap_staged_jar() {
|
|||||||
#
|
#
|
||||||
# The defect: the swap step used to be guarded inline by `if [ "$DO_BUILD" = 1 ]` in the main flow.
|
# The defect: the swap step used to be guarded inline by `if [ "$DO_BUILD" = 1 ]` in the main flow.
|
||||||
# Changing that to `if false` left the suite green and the swap never ran, so a redeploy reported
|
# Changing that to `if false` left the suite green and the swap never ran, so a redeploy reported
|
||||||
# every step succeeding while the daemon started on no jar at all (stage_built_jar has already moved
|
# every step succeeding while the daemon started on no jar at all or on a stale one.
|
||||||
# the freshly built one to $JAR_STAGED by then) or on a stale one.
|
|
||||||
# test_swap_ordered_after_wait_and_before_start could not catch it: it reads this script's own text
|
# test_swap_ordered_after_wait_and_before_start could not catch it: it reads this script's own text
|
||||||
# and compares line positions, and a same-line edit moves no line.
|
# and compares line positions, and a same-line edit moves no line.
|
||||||
#
|
#
|
||||||
@@ -312,7 +325,12 @@ swap_if_built() {
|
|||||||
local do_build="$1"
|
local do_build="$1"
|
||||||
should_swap "$do_build" || return 0
|
should_swap "$do_build" || return 0
|
||||||
say "swap"
|
say "swap"
|
||||||
swap_staged_jar "$JAR_STAGED" "$JAR"
|
# fleetd #664: $JAR now lives under run/, a directory target/ never created. mkdir -p here,
|
||||||
|
# not inside swap_staged_jar itself — that function's own contract is tested on a missing
|
||||||
|
# parent directory (a failing mv), and widening it to auto-create one would change what that
|
||||||
|
# test proves.
|
||||||
|
mkdir -p "$(dirname "$JAR")"
|
||||||
|
swap_staged_jar "$BUILD_JAR" "$JAR"
|
||||||
ok "jar in place: $(jar_id)"
|
ok "jar in place: $(jar_id)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,6 +628,45 @@ check_log_path_matches_plist() {
|
|||||||
ok "log path check: script and plist agree ($resolved_out)"
|
ok "log path check: script and plist agree ($resolved_out)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Reads the launchd plist's ProgramArguments for the argument that follows "-jar", resolves it
|
||||||
|
# alongside $jar_path, and dies when the two differ. Call it only when the agent is loaded; it
|
||||||
|
# never touches launchd or the daemon itself.
|
||||||
|
check_jar_path_matches_plist() {
|
||||||
|
local jar_path="$1" plist_path="$2"
|
||||||
|
local plist_args plist_jar resolved_jar resolved_plist_jar
|
||||||
|
if ! plist_args="$(/usr/libexec/PlistBuddy -c 'Print :ProgramArguments' "$plist_path" 2>/dev/null)"; then
|
||||||
|
die "launchd agent is loaded but PlistBuddy could not read ProgramArguments from
|
||||||
|
$plist_path
|
||||||
|
— cannot verify which jar the supervised daemon launches. Fix the plist before
|
||||||
|
redeploying supervised."
|
||||||
|
fi
|
||||||
|
plist_jar="$(printf '%s\n' "$plist_args" | awk '
|
||||||
|
{ gsub(/^[ \t]+|[ \t]+$/, "") }
|
||||||
|
prev == "-jar" { print; exit }
|
||||||
|
{ prev = $0 }
|
||||||
|
')"
|
||||||
|
if [ -z "$plist_jar" ]; then
|
||||||
|
die "launchd agent is loaded but its ProgramArguments at
|
||||||
|
$plist_path
|
||||||
|
do not contain a '-jar <path>' pair — cannot verify which jar the supervised daemon
|
||||||
|
launches. Fix the plist before redeploying supervised."
|
||||||
|
fi
|
||||||
|
resolved_jar="$(cd "$(dirname "$jar_path")" 2>/dev/null && pwd -P)/$(basename "$jar_path")" || true
|
||||||
|
resolved_plist_jar="$(cd "$(dirname "$plist_jar")" 2>/dev/null && pwd -P)/$(basename "$plist_jar")" || true
|
||||||
|
if [ -z "$resolved_jar" ] || [ -z "$resolved_plist_jar" ] || [ "$resolved_jar" != "$resolved_plist_jar" ]; then
|
||||||
|
die "jar path mismatch — this script deploys to
|
||||||
|
$jar_path (resolved: ${resolved_jar:-<directory does not exist>})
|
||||||
|
but the loaded plist's ProgramArguments names
|
||||||
|
$plist_jar (resolved: ${resolved_plist_jar:-<directory does not exist>})
|
||||||
|
The swap renames the built jar into place, so the old path stops existing after a redeploy;
|
||||||
|
a launchd-initiated start from this plist (a reboot, or KeepAlive after a crash) would then
|
||||||
|
run java against a missing file. Reinstall the plist at
|
||||||
|
$plist_path
|
||||||
|
so its ProgramArguments names $jar_path before redeploying supervised."
|
||||||
|
fi
|
||||||
|
ok "jar path check: script and plist agree ($resolved_jar)"
|
||||||
|
}
|
||||||
|
|
||||||
# fleetd #552: the post-restart fresh-log capture, pulled out of the main flow so it is testable by
|
# fleetd #552: the post-restart fresh-log capture, pulled out of the main flow so it is testable by
|
||||||
# sourcing (the same reason systemd_installed/systemd_loaded above guard their OWN mktemp inline
|
# sourcing (the same reason systemd_installed/systemd_loaded above guard their OWN mktemp inline
|
||||||
# instead of leaving it bare) even though its only caller sits below the SOURCED guard. By the time
|
# instead of leaving it bare) even though its only caller sits below the SOURCED guard. By the time
|
||||||
@@ -826,16 +883,25 @@ report_shutdown_drain() {
|
|||||||
# --no-build, staged jar present -> ALSO "nothing changed", deliberately: --no-build itself builds
|
# --no-build, staged jar present -> ALSO "nothing changed", deliberately: --no-build itself builds
|
||||||
# and stages nothing (see require_no_build_jar above), so a staged jar found here is a leftover
|
# and stages nothing (see require_no_build_jar above), so a staged jar found here is a leftover
|
||||||
# from an earlier, unrelated run. THIS run truly changed nothing, and the next DO_BUILD=1 run
|
# from an earlier, unrelated run. THIS run truly changed nothing, and the next DO_BUILD=1 run
|
||||||
# wipes that leftover before it builds (`rm -f "$JAR_STAGED"` in the build section above) — so
|
# wipes that leftover for free — `mvn clean` deletes all of target/, $BUILD_JAR included,
|
||||||
# there is nothing here for the operator to lose track of.
|
# before the build even starts — so there is nothing here for the operator to lose track of.
|
||||||
# --no-build, staged jar absent -> "nothing changed"
|
# --no-build, staged jar absent -> "nothing changed"
|
||||||
drain_gate_refusal() {
|
drain_gate_refusal() {
|
||||||
local do_build="$1" staged_path="$2"
|
local do_build="$1" staged_path="$2"
|
||||||
if [ "$do_build" = 1 ] && [ -f "$staged_path" ]; then
|
if [ "$do_build" = 1 ] && [ -f "$staged_path" ]; then
|
||||||
|
# fleetd #664: under the old layout a build emptied the live path ($JAR) immediately, so
|
||||||
|
# --no-build's own check ("no jar at $JAR") was the thing that refused a rerun here. That is
|
||||||
|
# no longer true: a build never touches $JAR at all now, so $JAR still holds whatever was
|
||||||
|
# already running before this gate fired (reaching this message at all requires OLD_PID to
|
||||||
|
# have been set, which means a daemon was running from $JAR already) — a --no-build rerun
|
||||||
|
# would NOT refuse, it would just restart that same old jar and silently throw away the one
|
||||||
|
# sitting at %s.
|
||||||
printf 'aborted — the running daemon was NOT touched, but the freshly built jar is sitting at
|
printf 'aborted — the running daemon was NOT touched, but the freshly built jar is sitting at
|
||||||
%s, not yet swapped into %s. Rerun WITHOUT --no-build to finish the restart —
|
%s, not yet swapped into %s. Rerun WITHOUT --no-build to finish the restart — a
|
||||||
the freshly built jar is no longer at the live path that --no-build requires — or
|
--no-build rerun would NOT refuse here: %s already exists from before this run, so it
|
||||||
remove %s by hand if you want to discard this build.' "$staged_path" "$JAR" "$staged_path"
|
would restart the daemon on that OLD jar and silently discard the one you just built — or
|
||||||
|
remove %s by hand if you want to discard this build instead.' \
|
||||||
|
"$staged_path" "$JAR" "$JAR" "$staged_path"
|
||||||
else
|
else
|
||||||
printf 'aborted — nothing changed'
|
printf 'aborted — nothing changed'
|
||||||
fi
|
fi
|
||||||
@@ -934,6 +1000,7 @@ report_supervisor_state() {
|
|||||||
SUPERVISED=1
|
SUPERVISED=1
|
||||||
ok "launchd agent loaded ($LAUNCHD_LABEL) — launchd supervises this daemon"
|
ok "launchd agent loaded ($LAUNCHD_LABEL) — launchd supervises this daemon"
|
||||||
check_log_path_matches_plist "$OUT" "$LAUNCHD_PLIST"
|
check_log_path_matches_plist "$OUT" "$LAUNCHD_PLIST"
|
||||||
|
check_jar_path_matches_plist "$JAR" "$LAUNCHD_PLIST"
|
||||||
;;
|
;;
|
||||||
systemd)
|
systemd)
|
||||||
SUPERVISED=1
|
SUPERVISED=1
|
||||||
@@ -1170,7 +1237,7 @@ if [ -n "$OLD_PID" ]; then
|
|||||||
else
|
else
|
||||||
warn "no daemon running — this will be a cold start"
|
warn "no daemon running — this will be a cold start"
|
||||||
fi
|
fi
|
||||||
ok "jar on disk: $(jar_id) ($([ -f "$JAR" ] && date -r "$JAR" '+%Y-%m-%d %H:%M:%S' || echo 'none'))"
|
report_jar_state
|
||||||
ok "HEAD: $(git -C "$REPO" log --oneline -1)"
|
ok "HEAD: $(git -C "$REPO" log --oneline -1)"
|
||||||
|
|
||||||
# CB-594 / fleetd #492: supervision state. Installed and loaded are different facts — a
|
# CB-594 / fleetd #492: supervision state. Installed and loaded are different facts — a
|
||||||
@@ -1252,9 +1319,6 @@ stop_if_check_only "$CHECK_ONLY"
|
|||||||
|
|
||||||
if [ "$DO_BUILD" = 1 ]; then
|
if [ "$DO_BUILD" = 1 ]; then
|
||||||
say "build"
|
say "build"
|
||||||
# fleetd #493: wipe a leftover staged jar from a previous failed/interrupted run BEFORE doing
|
|
||||||
# anything else, so that run's leftovers can never be mistaken for this run's output.
|
|
||||||
rm -f "$JAR_STAGED"
|
|
||||||
BUILD_LOG="$(mktemp -t fleetd-build.XXXXXX)"
|
BUILD_LOG="$(mktemp -t fleetd-build.XXXXXX)"
|
||||||
echo " log: $BUILD_LOG"
|
echo " log: $BUILD_LOG"
|
||||||
if ! mvn -f "$MODULE/pom.xml" clean install > "$BUILD_LOG" 2>&1; then
|
if ! mvn -f "$MODULE/pom.xml" clean install > "$BUILD_LOG" 2>&1; then
|
||||||
@@ -1264,16 +1328,16 @@ if [ "$DO_BUILD" = 1 ]; then
|
|||||||
fi
|
fi
|
||||||
grep -E '^\[INFO\] Tests run:.*Failures' "$BUILD_LOG" | tail -1 | sed 's/^\[INFO\] / /' || true
|
grep -E '^\[INFO\] Tests run:.*Failures' "$BUILD_LOG" | tail -1 | sed 's/^\[INFO\] / /' || true
|
||||||
ok "BUILD SUCCESS"
|
ok "BUILD SUCCESS"
|
||||||
# fleetd #493: move the freshly built jar off the live path immediately — the running (OLD)
|
# fleetd #664: nothing to stage — $BUILD_JAR (target/fleetd.jar) is Maven's own output path and
|
||||||
# daemon, if any, is still up at this point (build always runs before stop). From here until the
|
# was never the live path, so the running (OLD) daemon, if any, was never at risk from this build
|
||||||
# swap step below (after the OLD daemon is confirmed gone), $JAR_STAGED is the only artefact this
|
# at all. From here until the swap step below (after the OLD daemon is confirmed gone),
|
||||||
# script treats as "the new jar" — $JAR itself is not touched again until the swap.
|
# $BUILD_JAR is the artefact this script treats as "the new jar" — $JAR itself is not touched
|
||||||
stage_built_jar
|
# again until the swap.
|
||||||
ok "jar now: $(jar_id "$JAR_STAGED")"
|
ok "jar now: $(jar_id "$BUILD_JAR")"
|
||||||
else
|
else
|
||||||
say "build skipped (--no-build)"
|
say "build skipped (--no-build)"
|
||||||
# fleetd #493: --no-build never builds or stages anything — it restarts whatever jar is already
|
# fleetd #493: --no-build never builds anything — it restarts whatever jar is already sitting at
|
||||||
# sitting at the live path from an earlier successful run. Same check, same message as before.
|
# the live path from an earlier successful run. Same check, same message as before.
|
||||||
require_no_build_jar
|
require_no_build_jar
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1282,8 +1346,8 @@ fi
|
|||||||
# fleetd #555: run_drain_gate above is drain_gate_required + the prompt + drain_confirmed, called
|
# fleetd #555: run_drain_gate above is drain_gate_required + the prompt + drain_confirmed, called
|
||||||
# unconditionally — it returns immediately when the gate is not required, and composes/dies through
|
# unconditionally — it returns immediately when the gate is not required, and composes/dies through
|
||||||
# refuse_drain_gate itself when the reply does not confirm. See #493/#517/#528 for why "nothing
|
# refuse_drain_gate itself when the reply does not confirm. See #493/#517/#528 for why "nothing
|
||||||
# changed" would be a lie once a build has staged a jar.
|
# changed" would be a lie once a build has produced a jar not yet swapped in.
|
||||||
run_drain_gate "$OLD_PID" "$ASSUME_YES" "$DO_BUILD" "$JAR_STAGED"
|
run_drain_gate "$OLD_PID" "$ASSUME_YES" "$DO_BUILD" "$BUILD_JAR"
|
||||||
|
|
||||||
# ------------------------------------------------------------------ stop
|
# ------------------------------------------------------------------ stop
|
||||||
#
|
#
|
||||||
@@ -1334,21 +1398,22 @@ fi
|
|||||||
|
|
||||||
# ------------------------------------------------------------------ swap
|
# ------------------------------------------------------------------ swap
|
||||||
#
|
#
|
||||||
# fleetd #493: every branch above has now either confirmed the OLD daemon actually exited
|
# fleetd #493/#664: every branch above has now either confirmed the OLD daemon actually exited
|
||||||
# (wait_for_daemon_exit, above) or established there was never one running to begin with. Only
|
# (wait_for_daemon_exit, above) or established there was never one running to begin with. Only NOW
|
||||||
# NOW is it safe to put the freshly built jar at the path the NEXT `java -jar` (direct, or via
|
# is it safe to put the freshly built jar at the path the NEXT `java -jar` (direct, or via
|
||||||
# launchd/systemd's ExecStart) will read from — this mv is the one and only write to $JAR anywhere
|
# launchd/systemd's ExecStart) will read from — a rename from $BUILD_JAR (target/) to $JAR (run/),
|
||||||
|
# both under $MODULE and so on one filesystem. This mv is the one and only write to $JAR anywhere
|
||||||
# in this script's mutating flow. If it fails, do not start: die() below exits before "start" runs.
|
# in this script's mutating flow. If it fails, do not start: die() below exits before "start" runs.
|
||||||
swap_if_built "$DO_BUILD"
|
swap_if_built "$DO_BUILD"
|
||||||
|
|
||||||
# ------------------------------------------------------------------ start
|
# ------------------------------------------------------------------ start
|
||||||
# Unsupervised: login shell (zsh -l) is what puts the secrets on the daemon's environment, and cwd
|
# Unsupervised: login shell (zsh -l) is what puts the secrets on the daemon's environment, and cwd
|
||||||
# must be fleetd/ because the daemon resolves fleetd.yaml, logs/ and target/ relative to it.
|
# must be fleetd/ because the daemon resolves fleetd.yaml, logs/ and run/ relative to it.
|
||||||
# Supervised (launchd): launchd does both — deploy/dev.ltms.fleetd.plist points ProgramArguments at
|
# Supervised (launchd): launchd does both — deploy/dev.ltms.fleetd.plist points ProgramArguments at
|
||||||
# scripts/fleetd-launchd-wrapper.sh (CB-594), which is what execs the login shell in launchd's
|
# scripts/fleetd-launchd-wrapper.sh (CB-594), which is what execs the login shell in launchd's
|
||||||
# place, and WorkingDirectory in the plist already pins fleetd/.
|
# place, and WorkingDirectory in the plist already pins fleetd/.
|
||||||
# Supervised (systemd --user): the unit does both too — measured on the second host, ExecStart is
|
# Supervised (systemd --user): the unit does both too — measured on the second host, ExecStart is
|
||||||
# `/bin/zsh -lc "exec java -jar target/fleetd.jar fleetd.yaml"` (a login shell, same reason as
|
# `/bin/zsh -lc "exec java -jar run/fleetd.jar fleetd.yaml"` (a login shell, same reason as
|
||||||
# above) and WorkingDirectory is already pinned to fleetd/.
|
# above) and WorkingDirectory is already pinned to fleetd/.
|
||||||
|
|
||||||
say "start"
|
say "start"
|
||||||
|
|||||||
@@ -233,6 +233,66 @@ test_redaction_holds() {
|
|||||||
assert_contains "weight" "$RUN_OUTPUT" "a diff must have been demonstrably printed at all"
|
assert_contains "weight" "$RUN_OUTPUT" "a diff must have been demonstrably printed at all"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# redact()'s key-name filter only inspects the KEY, so a diff line whose key does not match
|
||||||
|
# TOKEN|SECRET|PASSWORD|PASSWD|PASSPHRASE|CREDENTIAL|URI|_KEY still reaches the final userinfo
|
||||||
|
# sed even when its VALUE holds a credentialed URI. "note" is not a sensitive key name, so this
|
||||||
|
# line must fall all the way through to that sed, not the earlier whole-value branch. The
|
||||||
|
# trailing prose on both sides of the userinfo is a positive control: it proves the line reached
|
||||||
|
# the userinfo sed (which touches only the userinfo) rather than the earlier branch (which would
|
||||||
|
# have replaced the whole value with a bare "<redacted>" and dropped the prose).
|
||||||
|
test_diff_line_userinfo_is_masked_with_positive_control() {
|
||||||
|
local dir
|
||||||
|
dir="$(new_fixture)"
|
||||||
|
|
||||||
|
start_run "$dir" 5 --set '.profiles.sonnet.note=see amqp://alice:wonderland@rabbit.local:5672/vhost for details'
|
||||||
|
sleep 1
|
||||||
|
printf 'config reloaded\n' >> "$dir/fleetd.out"
|
||||||
|
collect_run "$dir"
|
||||||
|
|
||||||
|
assert_equals 0 "$RUN_RC" "diff-userinfo-case reload exit code"
|
||||||
|
assert_not_contains "alice:wonderland" "$RUN_OUTPUT" "the userinfo must never reach the output"
|
||||||
|
assert_contains "amqp://<redacted>@rabbit.local:5672/vhost" "$RUN_OUTPUT" \
|
||||||
|
"the userinfo must be MASKED, not deleted — the rest of the value must survive"
|
||||||
|
assert_contains "note:" "$RUN_OUTPUT" "the key name must still reach the output"
|
||||||
|
assert_contains "see " "$RUN_OUTPUT" "prose BEFORE the userinfo must still reach the output"
|
||||||
|
assert_contains "for details" "$RUN_OUTPUT" "prose AFTER the userinfo must still reach the output"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A diff line can hold a URL with no userinfo, followed later on the same line by an unrelated @
|
||||||
|
# (free text in a string value, for example an email address). The line must pass through the
|
||||||
|
# userinfo sed byte for byte: the match must stop at the end of the URL and must not treat the
|
||||||
|
# later @ as a second userinfo delimiter.
|
||||||
|
test_diff_line_uri_without_userinfo_survives_a_later_at_sign() {
|
||||||
|
local dir
|
||||||
|
dir="$(new_fixture)"
|
||||||
|
|
||||||
|
start_run "$dir" 5 --set '.profiles.sonnet.note2=see https://docs.local/guide and mail ops@example.com'
|
||||||
|
sleep 1
|
||||||
|
printf 'config reloaded\n' >> "$dir/fleetd.out"
|
||||||
|
collect_run "$dir"
|
||||||
|
|
||||||
|
assert_equals 0 "$RUN_RC" "diff-no-userinfo-with-later-at-sign reload exit code"
|
||||||
|
assert_contains "note2: see https://docs.local/guide and mail ops@example.com" "$RUN_OUTPUT" \
|
||||||
|
"a URL with no userinfo plus a later @ on the same line must pass through byte for byte"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Two credentialed URIs on one diff line must both be masked — the g flag matters.
|
||||||
|
test_diff_line_masks_multiple_userinfo_with_g_flag() {
|
||||||
|
local dir
|
||||||
|
dir="$(new_fixture)"
|
||||||
|
|
||||||
|
start_run "$dir" 5 --set '.profiles.sonnet.note3=amqp://u1:p1@host1/vhost1 and amqp://u2:p2@host2/vhost2'
|
||||||
|
sleep 1
|
||||||
|
printf 'config reloaded\n' >> "$dir/fleetd.out"
|
||||||
|
collect_run "$dir"
|
||||||
|
|
||||||
|
assert_equals 0 "$RUN_RC" "diff-two-userinfo-on-one-line reload exit code"
|
||||||
|
assert_not_contains "u1:p1" "$RUN_OUTPUT" "the first userinfo must never reach the output"
|
||||||
|
assert_not_contains "u2:p2" "$RUN_OUTPUT" "the second userinfo must never reach the output"
|
||||||
|
assert_contains "amqp://<redacted>@host1/vhost1" "$RUN_OUTPUT" "the first URI must be masked"
|
||||||
|
assert_contains "amqp://<redacted>@host2/vhost2" "$RUN_OUTPUT" "the second URI must be masked"
|
||||||
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------- acceptance criterion 9: forgotten value
|
# ------------------------------------------------------- acceptance criterion 9: forgotten value
|
||||||
# `--set .a.b=` is a plausible typo (the value simply forgotten), and it must be refused outright
|
# `--set .a.b=` is a plausible typo (the value simply forgotten), and it must be refused outright
|
||||||
# rather than silently nulling the field — a null numeric field falls back to its default, which
|
# rather than silently nulling the field — a null numeric field falls back to its default, which
|
||||||
@@ -629,6 +689,65 @@ test_refusal_shape_from_parse_failure_wording_is_recognised() {
|
|||||||
assert_equals 4 "$RUN_RC" "the parse-failure refusal shape must also exit 4, not be read as silence"
|
assert_equals 4 "$RUN_RC" "the parse-failure refusal shape must also exit 4, not be read as silence"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# A verdict line carrying a credentialed URI has its userinfo masked, with a positive control
|
||||||
|
# proving the rest of the line still reaches the output unchanged.
|
||||||
|
test_verdict_userinfo_is_masked_with_positive_control() {
|
||||||
|
local dir
|
||||||
|
dir="$(new_fixture)"
|
||||||
|
|
||||||
|
start_run "$dir" 5 --set '.profiles.sonnet.weight=4'
|
||||||
|
sleep 1
|
||||||
|
printf 'config reload from %s refused, keeping the running config: refusing to start: malformed pattern — profiles.local.errorPattern ("amqp://user:hunter2@host/vhost"): Unclosed character class near index 8\n' \
|
||||||
|
"$dir/fleetd.yaml" >> "$dir/fleetd.out"
|
||||||
|
collect_run "$dir"
|
||||||
|
|
||||||
|
assert_equals 4 "$RUN_RC" "refusal-with-userinfo exit code"
|
||||||
|
assert_not_contains "user:hunter2" "$RUN_OUTPUT" "the userinfo must never reach the output"
|
||||||
|
assert_contains "amqp://<redacted>@host/vhost" "$RUN_OUTPUT" \
|
||||||
|
"the userinfo must be MASKED, not deleted — the rest of the quoted value must survive"
|
||||||
|
# Positive control: the diagnostic prose on both sides of the userinfo must still reach the
|
||||||
|
# output. Without this, a mutant that drops the whole verdict line would pass identically.
|
||||||
|
assert_contains "malformed pattern" "$RUN_OUTPUT" "prose BEFORE the userinfo must still reach the output"
|
||||||
|
assert_contains "Unclosed character class near index 8" "$RUN_OUTPUT" \
|
||||||
|
"prose AFTER the userinfo must still reach the output"
|
||||||
|
}
|
||||||
|
|
||||||
|
# An ordinary refusal line quotes the offending pattern, not a credential, and must survive byte
|
||||||
|
# for byte: the rewrite is scoped to userinfo only, and the quoted pattern is the detail an
|
||||||
|
# operator needs to fix the refusal.
|
||||||
|
test_ordinary_refusal_line_passes_through_unchanged() {
|
||||||
|
local dir real_line
|
||||||
|
dir="$(new_fixture)"
|
||||||
|
real_line="config reload from $dir/fleetd.yaml refused, keeping the running config: refusing to start: malformed pattern — profiles.local.errorPattern (\"[unclosed\"): Unclosed character class near index 8"
|
||||||
|
|
||||||
|
start_run "$dir" 5 --set '.profiles.sonnet.weight=4'
|
||||||
|
sleep 1
|
||||||
|
printf '%s\n' "$real_line" >> "$dir/fleetd.out"
|
||||||
|
collect_run "$dir"
|
||||||
|
|
||||||
|
assert_equals 4 "$RUN_RC" "ordinary refusal exit code"
|
||||||
|
assert_contains "$real_line" "$RUN_OUTPUT" \
|
||||||
|
"an ordinary refusal with no userinfo must pass through byte for byte, unchanged"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A verdict line can hold a URI with NO userinfo and a later, unrelated @ further on in the same
|
||||||
|
# line (an email address in diagnostic prose, for example). The rewrite must stop at the end of
|
||||||
|
# the URI and must not treat the later @ as a second userinfo delimiter.
|
||||||
|
test_uri_without_userinfo_survives_a_later_at_sign() {
|
||||||
|
local dir real_line
|
||||||
|
dir="$(new_fixture)"
|
||||||
|
real_line="config reload from $dir/fleetd.yaml refused, keeping the running config: broker.uri amqp://broker.local/vhost unreachable, contact ops@example.com"
|
||||||
|
|
||||||
|
start_run "$dir" 5 --set '.profiles.sonnet.weight=4'
|
||||||
|
sleep 1
|
||||||
|
printf '%s\n' "$real_line" >> "$dir/fleetd.out"
|
||||||
|
collect_run "$dir"
|
||||||
|
|
||||||
|
assert_equals 4 "$RUN_RC" "no-userinfo-with-later-at-sign exit code"
|
||||||
|
assert_contains "$real_line" "$RUN_OUTPUT" \
|
||||||
|
"a URI with no userinfo plus a later @ in the same line must pass through byte for byte"
|
||||||
|
}
|
||||||
|
|
||||||
# --set runs yq over the whole candidate. It warns when that changes more lines than the requested
|
# --set runs yq over the whole candidate. It warns when that changes more lines than the requested
|
||||||
# pairs, but a simple file with only the intended changed line must stay quiet.
|
# pairs, but a simple file with only the intended changed line must stay quiet.
|
||||||
new_fixture_reformat_sensitive() {
|
new_fixture_reformat_sensitive() {
|
||||||
@@ -694,6 +813,12 @@ echo "== acceptance criterion 6: the marker works =="
|
|||||||
test_marker_skips_lines_before_it
|
test_marker_skips_lines_before_it
|
||||||
echo "== acceptance criterion 7 (+13: redaction is proven to have run) =="
|
echo "== acceptance criterion 7 (+13: redaction is proven to have run) =="
|
||||||
test_redaction_holds
|
test_redaction_holds
|
||||||
|
echo "== fleetd #692: a diff line's userinfo is masked, rest of the value survives =="
|
||||||
|
test_diff_line_userinfo_is_masked_with_positive_control
|
||||||
|
echo "== fleetd #692: a diff line's URI with no userinfo survives a later @ in the line =="
|
||||||
|
test_diff_line_uri_without_userinfo_survives_a_later_at_sign
|
||||||
|
echo "== fleetd #692: two userinfo URIs on one diff line are both masked =="
|
||||||
|
test_diff_line_masks_multiple_userinfo_with_g_flag
|
||||||
echo "== acceptance criterion 9: a forgotten value refuses and installs nothing =="
|
echo "== acceptance criterion 9: a forgotten value refuses and installs nothing =="
|
||||||
test_forgotten_value_refuses_and_installs_nothing
|
test_forgotten_value_refuses_and_installs_nothing
|
||||||
echo "== acceptance criterion 10: an explicit clear writes a bare null =="
|
echo "== acceptance criterion 10: an explicit clear writes a bare null =="
|
||||||
@@ -720,6 +845,12 @@ echo "== extra: --check is read-only and always exits 0 =="
|
|||||||
test_check_is_read_only_and_exits_zero
|
test_check_is_read_only_and_exits_zero
|
||||||
echo "== extra: the parse-failure refusal shape is also recognised =="
|
echo "== extra: the parse-failure refusal shape is also recognised =="
|
||||||
test_refusal_shape_from_parse_failure_wording_is_recognised
|
test_refusal_shape_from_parse_failure_wording_is_recognised
|
||||||
|
echo "== verdict-redaction criteria 2+3: verdict userinfo is masked, rest of line survives =="
|
||||||
|
test_verdict_userinfo_is_masked_with_positive_control
|
||||||
|
echo "== verdict-redaction criterion 4: an ordinary refusal passes through unchanged =="
|
||||||
|
test_ordinary_refusal_line_passes_through_unchanged
|
||||||
|
echo "== fleetd #638: a URI with no userinfo survives a later @ in the same line =="
|
||||||
|
test_uri_without_userinfo_survives_a_later_at_sign
|
||||||
echo "== acceptance criterion 17: --set warns about yq formatting churn =="
|
echo "== acceptance criterion 17: --set warns about yq formatting churn =="
|
||||||
test_set_warns_when_yq_reformats_extra_lines
|
test_set_warns_when_yq_reformats_extra_lines
|
||||||
echo "== acceptance criterion 18: --set stays quiet without formatting churn =="
|
echo "== acceptance criterion 18: --set stays quiet without formatting churn =="
|
||||||
|
|||||||
+213
-63
@@ -447,7 +447,7 @@ test_assert_single_daemon_rejects_two_pids() {
|
|||||||
test_running_pid_excludes_self_matching_wrapper_shell() {
|
test_running_pid_excludes_self_matching_wrapper_shell() {
|
||||||
local before after wrapper_pid
|
local before after wrapper_pid
|
||||||
before="$(running_pid)"
|
before="$(running_pid)"
|
||||||
sh -c 'echo "target/fleetd.jar" >/dev/null; sleep 20' &
|
sh -c 'echo "run/fleetd.jar" >/dev/null; sleep 20' &
|
||||||
wrapper_pid=$!
|
wrapper_pid=$!
|
||||||
sleep 0.3
|
sleep 0.3
|
||||||
after="$(running_pid)"
|
after="$(running_pid)"
|
||||||
@@ -475,11 +475,26 @@ test_running_pid_excludes_self_matching_wrapper_shell() {
|
|||||||
# `comm` from the actually-executed binary's own path, not from `exec -a`'s argv[0] override (BSD
|
# `comm` from the actually-executed binary's own path, not from `exec -a`'s argv[0] override (BSD
|
||||||
# ties `comm` to argv[0], which is what makes this technique work here) — so on Linux this
|
# ties `comm` to argv[0], which is what makes this technique work here) — so on Linux this
|
||||||
# specific fixture might report `comm=sh`, not `comm=java`, even though the REAL daemon (a literal
|
# specific fixture might report `comm=sh`, not `comm=java`, even though the REAL daemon (a literal
|
||||||
# `java -jar target/fleetd.jar` process, never fabricated) is unaffected either way. I could not
|
# `java -jar run/fleetd.jar` process, never fabricated) is unaffected either way. I could not
|
||||||
# verify this fixture's behavior on Linux, so test_running_pid_counts_a_pid_whose_comm_is_java
|
# verify this fixture's behavior on Linux, so test_running_pid_counts_a_pid_whose_comm_is_java
|
||||||
# below backstops the same claim (the allowlist admits a pid whose comm is `java`) with a stubbed
|
# below backstops the same claim (the allowlist admits a pid whose comm is `java`) with a stubbed
|
||||||
# `ps`, which is identical bash on every platform and carries no such platform question.
|
# `ps`, which is identical bash on every platform and carries no such platform question.
|
||||||
test_running_pid_finds_a_real_java_named_second_process() {
|
test_running_pid_finds_a_real_java_named_second_process() {
|
||||||
|
local before after standin_pid
|
||||||
|
before="$(running_pid)"
|
||||||
|
( exec -a java sh -c 'echo "run/fleetd.jar" >/dev/null; sleep 20' ) &
|
||||||
|
standin_pid=$!
|
||||||
|
sleep 0.3
|
||||||
|
after="$(running_pid)"
|
||||||
|
kill "$standin_pid" 2>/dev/null || true
|
||||||
|
wait "$standin_pid" 2>/dev/null || true
|
||||||
|
printf '%s\n' "$after" | grep -qxF "$standin_pid" \
|
||||||
|
|| fail "running_pid() did not find a real second process (pid $standin_pid, comm forced to 'java' via exec -a) whose own argv holds the pattern: before=[$before] after=[$after]"
|
||||||
|
}
|
||||||
|
|
||||||
|
# PATTERN matches a fleetd jar in either build layout, not only the run/ one: a process whose
|
||||||
|
# argv names a jar under target/ must be found too, the same way the run/ case above is.
|
||||||
|
test_running_pid_finds_a_real_java_named_process_from_target_dir() {
|
||||||
local before after standin_pid
|
local before after standin_pid
|
||||||
before="$(running_pid)"
|
before="$(running_pid)"
|
||||||
( exec -a java sh -c 'echo "target/fleetd.jar" >/dev/null; sleep 20' ) &
|
( exec -a java sh -c 'echo "target/fleetd.jar" >/dev/null; sleep 20' ) &
|
||||||
@@ -489,7 +504,24 @@ test_running_pid_finds_a_real_java_named_second_process() {
|
|||||||
kill "$standin_pid" 2>/dev/null || true
|
kill "$standin_pid" 2>/dev/null || true
|
||||||
wait "$standin_pid" 2>/dev/null || true
|
wait "$standin_pid" 2>/dev/null || true
|
||||||
printf '%s\n' "$after" | grep -qxF "$standin_pid" \
|
printf '%s\n' "$after" | grep -qxF "$standin_pid" \
|
||||||
|| fail "running_pid() did not find a real second process (pid $standin_pid, comm forced to 'java' via exec -a) whose own argv holds the pattern: before=[$before] after=[$after]"
|
|| fail "running_pid() did not find a real second process (pid $standin_pid, comm forced to 'java' via exec -a) naming a jar under target/: before=[$before] after=[$after]"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Broadening PATTERN to match both build layouts must not also broaden it into matching a
|
||||||
|
# non-exec'ing shell that merely holds the target/ text as a literal argument, the same
|
||||||
|
# self-matching shape test_running_pid_excludes_self_matching_wrapper_shell above excludes for
|
||||||
|
# the run/ text.
|
||||||
|
test_running_pid_excludes_self_matching_wrapper_shell_naming_target_dir() {
|
||||||
|
local before after wrapper_pid
|
||||||
|
before="$(running_pid)"
|
||||||
|
sh -c 'echo "target/fleetd.jar" >/dev/null; sleep 20' &
|
||||||
|
wrapper_pid=$!
|
||||||
|
sleep 0.3
|
||||||
|
after="$(running_pid)"
|
||||||
|
kill "$wrapper_pid" 2>/dev/null || true
|
||||||
|
wait "$wrapper_pid" 2>/dev/null || true
|
||||||
|
[ "$after" = "$before" ] \
|
||||||
|
|| fail "running_pid() counted a self-matching wrapper shell (pid $wrapper_pid, holding 'target/fleetd.jar' as literal text in its own argv, not the daemon): before=[$before] after=[$after]"
|
||||||
}
|
}
|
||||||
|
|
||||||
# fleetd #593 CORRECTION 1, hole 2 — the round-1 filter denied known shell names (sh/bash/zsh/
|
# fleetd #593 CORRECTION 1, hole 2 — the round-1 filter denied known shell names (sh/bash/zsh/
|
||||||
@@ -557,29 +589,30 @@ test_die_message_does_not_recommend_bare_pgrep_as_remediation() {
|
|||||||
|| fail "assert_single_daemon's die message does not say in words that a pattern can match the caller (fleetd #593)"
|
|| fail "assert_single_daemon's die message does not say in words that a pattern can match the caller (fleetd #593)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# fleetd #511 — jar_id()'s no-argument default was unpinned by any test: nothing proved it reports
|
# fleetd #511/#664 — jar_id()'s no-argument default was unpinned by any test: nothing proved it
|
||||||
# $JAR (the live path) rather than $JAR_STAGED. Both halves matter, so this pins both: the bare call
|
# reports $JAR (the live path) rather than whatever explicit path a caller passes it (e.g.
|
||||||
# must hash the live jar, and an explicit path argument must hash THAT file, not fall back to $JAR.
|
# $BUILD_JAR). Both halves matter, so this pins both: the bare call must hash the live jar, and an
|
||||||
# Two files with different content, so a default pointed at the wrong one reports the wrong hash
|
# explicit path argument must hash THAT file, not fall back to $JAR. Two files with different
|
||||||
# rather than accidentally matching.
|
# content, so a default pointed at the wrong one reports the wrong hash rather than accidentally
|
||||||
|
# matching.
|
||||||
test_jar_id_defaults_to_live_and_reports_explicit_path() {
|
test_jar_id_defaults_to_live_and_reports_explicit_path() {
|
||||||
local dir saved_jar="$JAR" saved_staged="$JAR_STAGED"
|
local dir saved_jar="$JAR"
|
||||||
local live_hash staged_hash default_result explicit_result
|
local live_hash other_hash default_result explicit_result other_path
|
||||||
dir="$TMP/jar-id"; mkdir -p "$dir"
|
dir="$TMP/jar-id"; mkdir -p "$dir"
|
||||||
JAR="$dir/fleetd.jar"; JAR_STAGED="$dir/fleetd-new.jar"
|
JAR="$dir/fleetd.jar"; other_path="$dir/other.jar"
|
||||||
printf 'live jar bytes' > "$JAR"
|
printf 'live jar bytes' > "$JAR"
|
||||||
printf 'staged jar bytes, not the same content' > "$JAR_STAGED"
|
printf 'other jar bytes, not the same content' > "$other_path"
|
||||||
# fleetd #550: this reference hash must be computed the same portable way jar_id() itself now
|
# fleetd #550: this reference hash must be computed the same portable way jar_id() itself now
|
||||||
# computes one — a bare, unguarded call to the macOS-only hasher here was exactly the item-2
|
# computes one — a bare, unguarded call to the macOS-only hasher here was exactly the item-2
|
||||||
# defect, dying with "command not found" on any Linux runner that has no such hasher at all.
|
# defect, dying with "command not found" on any Linux runner that has no such hasher at all.
|
||||||
live_hash="$(hash256 "$JAR")"
|
live_hash="$(hash256 "$JAR")"
|
||||||
staged_hash="$(hash256 "$JAR_STAGED")"
|
other_hash="$(hash256 "$other_path")"
|
||||||
default_result="$(jar_id)"
|
default_result="$(jar_id)"
|
||||||
explicit_result="$(jar_id "$JAR_STAGED")"
|
explicit_result="$(jar_id "$other_path")"
|
||||||
JAR="$saved_jar"; JAR_STAGED="$saved_staged"
|
JAR="$saved_jar"
|
||||||
[ "$live_hash" != "$staged_hash" ] || fail "test fixture error: live and staged jars hashed the same"
|
[ "$live_hash" != "$other_hash" ] || fail "test fixture error: live and other jars hashed the same"
|
||||||
assert_equals "$live_hash" "$default_result" "jar_id with no arguments must report the hash of \$JAR"
|
assert_equals "$live_hash" "$default_result" "jar_id with no arguments must report the hash of \$JAR"
|
||||||
assert_equals "$staged_hash" "$explicit_result" "jar_id \"\$JAR_STAGED\" must report the hash of the staged jar, not fall back to \$JAR"
|
assert_equals "$other_hash" "$explicit_result" "jar_id with an explicit path must report the hash of that path, not fall back to \$JAR"
|
||||||
}
|
}
|
||||||
|
|
||||||
# fleetd #550 — closes a gap the test above leaves open. That test's own reference hash is now ALSO
|
# fleetd #550 — closes a gap the test above leaves open. That test's own reference hash is now ALSO
|
||||||
@@ -651,34 +684,77 @@ test_jar_id_reports_unhashable_when_no_hasher_on_path() {
|
|||||||
assert_equals "unhashable" "$explicit_result" "jar_id (explicit path) with no hasher on PATH must report the same third state"
|
assert_equals "unhashable" "$explicit_result" "jar_id (explicit path) with no hasher on PATH must report the same third state"
|
||||||
}
|
}
|
||||||
|
|
||||||
# fleetd #493 — never build into the path a running process holds. stage_built_jar/swap_staged_jar
|
# fleetd #664 — report_jar_state is the --check fix: under the old layout $JAR and the build
|
||||||
# are exercised directly against real files on disk (not stubs), because the whole point is file
|
# output were the same file, so a single "jar on disk" fact covered both. Now they can disagree,
|
||||||
|
# and this is the function that is supposed to show that. Agreeing case: two files with IDENTICAL
|
||||||
|
# content must print both labels and never warn.
|
||||||
|
test_report_jar_state_agrees_when_hashes_match() {
|
||||||
|
local dir saved_build="$BUILD_JAR" saved_jar="$JAR" output
|
||||||
|
dir="$TMP/report-jar-agree"; mkdir -p "$dir"
|
||||||
|
BUILD_JAR="$dir/target-fleetd.jar"; JAR="$dir/run-fleetd.jar"
|
||||||
|
printf 'identical jar bytes' > "$BUILD_JAR"
|
||||||
|
printf 'identical jar bytes' > "$JAR"
|
||||||
|
output="$(report_jar_state)"
|
||||||
|
BUILD_JAR="$saved_build"; JAR="$saved_jar"
|
||||||
|
printf '%s' "$output" | grep -qF 'built jar' \
|
||||||
|
|| fail "report_jar_state did not label the built jar"
|
||||||
|
printf '%s' "$output" | grep -qF 'running jar' \
|
||||||
|
|| fail "report_jar_state did not label the running jar"
|
||||||
|
if printf '%s' "$output" | grep -qF 'differ'; then
|
||||||
|
fail "report_jar_state warned about a mismatch when both jars have identical content"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# The disagreeing case: this is the whole point of the ticket — a built jar that is NOT the
|
||||||
|
# running jar must be visibly flagged, not silently printed as two unremarkable facts.
|
||||||
|
test_report_jar_state_warns_when_hashes_differ() {
|
||||||
|
local dir saved_build="$BUILD_JAR" saved_jar="$JAR" output
|
||||||
|
dir="$TMP/report-jar-differ"; mkdir -p "$dir"
|
||||||
|
BUILD_JAR="$dir/target-fleetd.jar"; JAR="$dir/run-fleetd.jar"
|
||||||
|
printf 'freshly built jar bytes' > "$BUILD_JAR"
|
||||||
|
printf 'older running jar bytes' > "$JAR"
|
||||||
|
output="$(report_jar_state)"
|
||||||
|
BUILD_JAR="$saved_build"; JAR="$saved_jar"
|
||||||
|
printf '%s' "$output" | grep -qF 'differ' \
|
||||||
|
|| fail "report_jar_state did not warn when the built jar and running jar disagree"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Neither file existing (a fresh checkout, never built or deployed) must report two "absent"
|
||||||
|
# facts and never a false mismatch warning — "absent" vs "absent" is agreement, not a diff.
|
||||||
|
test_report_jar_state_both_absent_is_not_a_mismatch() {
|
||||||
|
local dir saved_build="$BUILD_JAR" saved_jar="$JAR" output
|
||||||
|
dir="$TMP/report-jar-absent"; mkdir -p "$dir"
|
||||||
|
BUILD_JAR="$dir/no-such-target.jar"; JAR="$dir/no-such-run.jar"
|
||||||
|
output="$(report_jar_state)"
|
||||||
|
BUILD_JAR="$saved_build"; JAR="$saved_jar"
|
||||||
|
printf '%s' "$output" | grep -qF 'absent' \
|
||||||
|
|| fail "report_jar_state did not report absent for a missing built/running jar"
|
||||||
|
if printf '%s' "$output" | grep -qF 'differ'; then
|
||||||
|
fail "report_jar_state warned about a mismatch when both jars are simply absent"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reads JAR and BUILD_JAR exactly as the script sources them, with nothing here assigning
|
||||||
|
# either first. JAR must resolve outside $MODULE/target/, and JAR must differ from BUILD_JAR:
|
||||||
|
# the daemon's live path and Maven's own build output are never the same file.
|
||||||
|
test_jar_and_build_jar_are_sourced_outside_target_and_differ() {
|
||||||
|
source "$ROOT/scripts/redeploy-fleetd.sh"
|
||||||
|
case "$JAR" in
|
||||||
|
"$MODULE"/target/*)
|
||||||
|
fail "\$JAR must not live under \$MODULE/target/ — got $JAR" ;;
|
||||||
|
esac
|
||||||
|
[ "$JAR" != "$BUILD_JAR" ] \
|
||||||
|
|| fail "\$JAR and \$BUILD_JAR must not be the same path — got $JAR"
|
||||||
|
}
|
||||||
|
|
||||||
|
# fleetd #493/#664 — never build into the path a running process holds. swap_staged_jar is
|
||||||
|
# exercised directly against real files on disk (not stubs), because the whole point is file
|
||||||
# behavior (does the content move, does the source disappear, does a failure leave both sides
|
# behavior (does the content move, does the source disappear, does a failure leave both sides
|
||||||
# intact) that a stubbed function cannot prove.
|
# intact) that a stubbed function cannot prove. stage_built_jar no longer exists: under the #664
|
||||||
test_stage_built_jar_moves_off_live_path() {
|
# layout $BUILD_JAR (target/fleetd.jar) was never the live path, so a build has nothing to be
|
||||||
local dir jar staged saved_jar="$JAR" saved_staged="$JAR_STAGED"
|
# staged OUT of — swap_staged_jar is called directly against $BUILD_JAR/$JAR (see
|
||||||
dir="$TMP/stage-ok"; mkdir -p "$dir"
|
# test_swap_ordered_after_wait_and_before_start and the report_jar_state tests below for the rest
|
||||||
jar="$dir/fleetd.jar"; staged="$dir/fleetd-new.jar"
|
# of that seam).
|
||||||
printf 'built jar bytes' > "$jar"
|
|
||||||
JAR="$jar"; JAR_STAGED="$staged"
|
|
||||||
stage_built_jar || fail "stage_built_jar rejected a real build output"
|
|
||||||
JAR="$saved_jar"; JAR_STAGED="$saved_staged"
|
|
||||||
[ ! -f "$jar" ] || fail "stage_built_jar left the jar behind at the live path $jar"
|
|
||||||
[ -f "$staged" ] || fail "stage_built_jar did not create the staged jar at $staged"
|
|
||||||
grep -qF 'built jar bytes' "$staged" || fail "staged jar does not carry the built content"
|
|
||||||
}
|
|
||||||
|
|
||||||
test_stage_built_jar_dies_when_build_produced_nothing() {
|
|
||||||
local dir output rc=0 saved_jar="$JAR" saved_staged="$JAR_STAGED"
|
|
||||||
dir="$TMP/stage-missing"; mkdir -p "$dir"
|
|
||||||
JAR="$dir/fleetd.jar"; JAR_STAGED="$dir/fleetd-new.jar"
|
|
||||||
output="$(stage_built_jar 2>&1)" || rc=$?
|
|
||||||
JAR="$saved_jar"; JAR_STAGED="$saved_staged"
|
|
||||||
[ "$rc" -ne 0 ] || fail "stage_built_jar accepted a missing build output"
|
|
||||||
printf '%s' "$output" | grep -qF "$dir/fleetd.jar" \
|
|
||||||
|| fail "refusal message does not name the missing jar path"
|
|
||||||
}
|
|
||||||
|
|
||||||
test_swap_staged_jar_moves_staged_onto_live() {
|
test_swap_staged_jar_moves_staged_onto_live() {
|
||||||
local dir staged live
|
local dir staged live
|
||||||
dir="$TMP/swap-ok"; mkdir -p "$dir"
|
dir="$TMP/swap-ok"; mkdir -p "$dir"
|
||||||
@@ -989,24 +1065,26 @@ test_swap_ordered_after_wait_and_before_start() {
|
|||||||
|| fail "swap_if_built (line $swap_line) is not before the start section (line $start_line)"
|
|| fail "swap_if_built (line $swap_line) is not before the start section (line $start_line)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# fleetd #511: the drain-gate abort message (fired when a build has staged a jar but the operator
|
# fleetd #511: the drain-gate abort message (fired when a build has produced a jar but the
|
||||||
# declines the drain confirmation) used to tell the operator to "Rerun (with or without --no-build)"
|
# operator declines the drain confirmation) used to tell the operator to "Rerun (with or without
|
||||||
# to finish the restart. That is wrong — by the time this message can fire, stage_built_jar has
|
# --no-build)" to finish the restart. That is wrong. fleetd #664 changed WHY it is wrong: under
|
||||||
# already moved the jar off $JAR, so a rerun WITH --no-build hits require_no_build_jar's own refusal
|
# the old layout a build emptied the live path immediately, so --no-build's own check refused a
|
||||||
# ("no jar at $JAR — run without --no-build"). Like test_swap_ordered_after_wait_and_before_start
|
# rerun for you; now a build never touches the live path at all, so --no-build would NOT refuse —
|
||||||
# above, this code path is never reached by sourcing (the SOURCED guard stops before the main flow),
|
# it would quietly restart the daemon on the OLD jar and throw away the one just built. Like
|
||||||
# so the only way to pin its exact wording is to read the source.
|
# test_swap_ordered_after_wait_and_before_start above, this code path is never reached by sourcing
|
||||||
|
# (the SOURCED guard stops before the main flow), so the only way to pin its exact wording is to
|
||||||
|
# read the source.
|
||||||
test_drain_gate_abort_message_says_no_no_build() {
|
test_drain_gate_abort_message_says_no_no_build() {
|
||||||
local src="$ROOT/scripts/redeploy-fleetd.sh" msg
|
local src="$ROOT/scripts/redeploy-fleetd.sh" msg
|
||||||
msg="$(grep -A3 -F 'aborted — the running daemon was NOT touched, but the freshly built jar is sitting at' "$src")"
|
msg="$(grep -A6 -F 'aborted — the running daemon was NOT touched, but the freshly built jar is sitting at' "$src")"
|
||||||
[ -n "$msg" ] || fail "could not find the drain-gate staged-jar abort message in redeploy-fleetd.sh"
|
[ -n "$msg" ] || fail "could not find the drain-gate staged-jar abort message in redeploy-fleetd.sh"
|
||||||
if printf '%s' "$msg" | grep -qF 'with or without --no-build'; then
|
if printf '%s' "$msg" | grep -qF 'with or without --no-build'; then
|
||||||
fail "abort message still claims a rerun WITH --no-build can finish the restart"
|
fail "abort message still claims a rerun WITH --no-build can finish the restart"
|
||||||
fi
|
fi
|
||||||
printf '%s' "$msg" | grep -qF 'WITHOUT --no-build' \
|
printf '%s' "$msg" | grep -qF 'WITHOUT --no-build' \
|
||||||
|| fail "abort message does not tell the operator to rerun without --no-build"
|
|| fail "abort message does not tell the operator to rerun without --no-build"
|
||||||
printf '%s' "$msg" | grep -qF 'no longer at the live path' \
|
printf '%s' "$msg" | grep -qF 'silently discard' \
|
||||||
|| fail "abort message does not say why --no-build cannot finish the restart"
|
|| fail "abort message does not say that --no-build would silently discard the build just made"
|
||||||
}
|
}
|
||||||
|
|
||||||
# fleetd #517 — the drain-gate abort branch itself. Before this, the only test of this message was
|
# fleetd #517 — the drain-gate abort branch itself. Before this, the only test of this message was
|
||||||
@@ -1149,19 +1227,22 @@ test_refuse_drain_gate_no_build_staged_absent() {
|
|||||||
#
|
#
|
||||||
# fleetd #555 — the main flow's own call site moved: it used to read
|
# fleetd #555 — the main flow's own call site moved: it used to read
|
||||||
# `refuse_drain_gate "$DO_BUILD" "$JAR_STAGED"` directly; it now reads
|
# `refuse_drain_gate "$DO_BUILD" "$JAR_STAGED"` directly; it now reads
|
||||||
# `run_drain_gate "$OLD_PID" "$ASSUME_YES" "$DO_BUILD" "$JAR_STAGED"`, and run_drain_gate (tested
|
# `run_drain_gate "$OLD_PID" "$ASSUME_YES" "$DO_BUILD" "$BUILD_JAR"` (fleetd #664 renamed the
|
||||||
# directly below by test_run_drain_gate_*) is what calls refuse_drain_gate with its own local names.
|
# fourth argument from $JAR_STAGED to $BUILD_JAR — same role, the not-yet-swapped-in jar — when
|
||||||
# This grep now pins THAT call site — the thing that would go missing if a future edit deleted the
|
# that path stopped being a separate staging file and became target/fleetd.jar itself), and
|
||||||
# main flow's call to run_drain_gate altogether, the same residual gap #521/#528 already accepted for
|
# run_drain_gate (tested directly below by test_run_drain_gate_*) is what calls refuse_drain_gate
|
||||||
# swap_if_built/refuse_drain_gate (sourcing stops before the main flow runs, so no test in this file
|
# with its own local names. This grep now pins THAT call site — the thing that would go missing if
|
||||||
# can do better than reading the source for this one specific gap).
|
# a future edit deleted the main flow's call to run_drain_gate altogether, the same residual gap
|
||||||
|
# #521/#528 already accepted for swap_if_built/refuse_drain_gate (sourcing stops before the main
|
||||||
|
# flow runs, so no test in this file can do better than reading the source for this one specific
|
||||||
|
# gap).
|
||||||
#
|
#
|
||||||
# The grep ends `|| true`: this file runs under `set -euo pipefail`, so an ABSENT needle would fail
|
# The grep ends `|| true`: this file runs under `set -euo pipefail`, so an ABSENT needle would fail
|
||||||
# the assignment and `set -e` would kill the whole suite before the `[ -n ... ] || fail` guard below
|
# the assignment and `set -e` would kill the whole suite before the `[ -n ... ] || fail` guard below
|
||||||
# ever ran — the exact dead-check shape fleetd #528 also flags as a sweep finding (see the PR body).
|
# ever ran — the exact dead-check shape fleetd #528 also flags as a sweep finding (see the PR body).
|
||||||
test_run_drain_gate_call_site_present() {
|
test_run_drain_gate_call_site_present() {
|
||||||
local src="$ROOT/scripts/redeploy-fleetd.sh" call_line
|
local src="$ROOT/scripts/redeploy-fleetd.sh" call_line
|
||||||
call_line="$(grep -Fn 'run_drain_gate "$OLD_PID" "$ASSUME_YES" "$DO_BUILD" "$JAR_STAGED"' "$src" | head -1 | cut -d: -f1 || true)"
|
call_line="$(grep -Fn 'run_drain_gate "$OLD_PID" "$ASSUME_YES" "$DO_BUILD" "$BUILD_JAR"' "$src" | head -1 | cut -d: -f1 || true)"
|
||||||
[ -n "$call_line" ] \
|
[ -n "$call_line" ] \
|
||||||
|| fail "could not find the main flow's run_drain_gate call site in redeploy-fleetd.sh"
|
|| fail "could not find the main flow's run_drain_gate call site in redeploy-fleetd.sh"
|
||||||
}
|
}
|
||||||
@@ -1269,12 +1350,71 @@ test_run_drain_gate_declined_reply_refuses() {
|
|||||||
source "$ROOT/scripts/redeploy-fleetd.sh"
|
source "$ROOT/scripts/redeploy-fleetd.sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Writes a launchd-plist fixture naming jar_path as the ProgramArguments entry after "-jar", so
|
||||||
|
# check_jar_path_matches_plist has something real to read back.
|
||||||
|
write_launchd_plist_fixture() {
|
||||||
|
local path="$1" jar_path="$2"
|
||||||
|
cat > "$path" <<PLIST
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>test.fixture</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/usr/bin/java</string>
|
||||||
|
<string>-jar</string>
|
||||||
|
<string>$jar_path</string>
|
||||||
|
<string>fleetd.yaml</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
}
|
||||||
|
|
||||||
|
# Agreeing case: a plist whose ProgramArguments names the same jar, resolved, must proceed and
|
||||||
|
# say so, never die.
|
||||||
|
test_check_jar_path_matches_plist_agrees_ok() {
|
||||||
|
local dir jar plist output rc=0
|
||||||
|
dir="$TMP/jar-path-agree"; mkdir -p "$dir/run"
|
||||||
|
jar="$dir/run/fleetd.jar"
|
||||||
|
plist="$dir/agree.plist"
|
||||||
|
write_launchd_plist_fixture "$plist" "$jar"
|
||||||
|
output="$(check_jar_path_matches_plist "$jar" "$plist" 2>&1)" || rc=$?
|
||||||
|
[ "$rc" -eq 0 ] \
|
||||||
|
|| fail "check_jar_path_matches_plist must succeed when the plist names the same jar: $output"
|
||||||
|
printf '%s' "$output" | grep -qF 'jar path check' \
|
||||||
|
|| fail "check_jar_path_matches_plist did not print the agreement line: $output"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The disagreeing case, and the positive control this check exists for: a plist naming a
|
||||||
|
# different jar must die, naming both paths.
|
||||||
|
test_check_jar_path_matches_plist_mismatch_dies() {
|
||||||
|
local dir jar other_jar plist output rc=0
|
||||||
|
dir="$TMP/jar-path-mismatch"; mkdir -p "$dir/run" "$dir/target"
|
||||||
|
jar="$dir/run/fleetd.jar"
|
||||||
|
other_jar="$dir/target/fleetd.jar"
|
||||||
|
plist="$dir/mismatch.plist"
|
||||||
|
write_launchd_plist_fixture "$plist" "$other_jar"
|
||||||
|
output="$(check_jar_path_matches_plist "$jar" "$plist" 2>&1)" || rc=$?
|
||||||
|
[ "$rc" -ne 0 ] \
|
||||||
|
|| fail "check_jar_path_matches_plist must die when the plist names a different jar"
|
||||||
|
printf '%s' "$output" | grep -qF "$jar" \
|
||||||
|
|| fail "die message does not name this script's jar path: $output"
|
||||||
|
printf '%s' "$output" | grep -qF "$other_jar" \
|
||||||
|
|| fail "die message does not name the plist's jar path: $output"
|
||||||
|
}
|
||||||
|
|
||||||
# fleetd #555 item 4 — the report-state dispatch on $SUPERVISOR_KIND. Inverting this used to report
|
# fleetd #555 item 4 — the report-state dispatch on $SUPERVISOR_KIND. Inverting this used to report
|
||||||
# the wrong supervisor and, for the launchd arm specifically, skip check_log_path_matches_plist.
|
# the wrong supervisor and, for the launchd arm specifically, skip check_log_path_matches_plist.
|
||||||
CHECK_LOG_PATH_CALLED=0
|
CHECK_LOG_PATH_CALLED=0
|
||||||
|
CHECK_JAR_PATH_CALLED=0
|
||||||
stub_check_log_path_recorder() {
|
stub_check_log_path_recorder() {
|
||||||
CHECK_LOG_PATH_CALLED=0
|
CHECK_LOG_PATH_CALLED=0
|
||||||
|
CHECK_JAR_PATH_CALLED=0
|
||||||
check_log_path_matches_plist() { CHECK_LOG_PATH_CALLED=1; }
|
check_log_path_matches_plist() { CHECK_LOG_PATH_CALLED=1; }
|
||||||
|
check_jar_path_matches_plist() { CHECK_JAR_PATH_CALLED=1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
test_report_supervisor_state_launchd_sets_supervised_and_checks_log_path() {
|
test_report_supervisor_state_launchd_sets_supervised_and_checks_log_path() {
|
||||||
@@ -1285,6 +1425,8 @@ test_report_supervisor_state_launchd_sets_supervised_and_checks_log_path() {
|
|||||||
[ "$SUPERVISED" = 1 ] || fail "report_supervisor_state launchd must set SUPERVISED=1"
|
[ "$SUPERVISED" = 1 ] || fail "report_supervisor_state launchd must set SUPERVISED=1"
|
||||||
[ "$CHECK_LOG_PATH_CALLED" = 1 ] \
|
[ "$CHECK_LOG_PATH_CALLED" = 1 ] \
|
||||||
|| fail "report_supervisor_state launchd must call check_log_path_matches_plist"
|
|| fail "report_supervisor_state launchd must call check_log_path_matches_plist"
|
||||||
|
[ "$CHECK_JAR_PATH_CALLED" = 1 ] \
|
||||||
|
|| fail "report_supervisor_state launchd must call check_jar_path_matches_plist"
|
||||||
source "$ROOT/scripts/redeploy-fleetd.sh"
|
source "$ROOT/scripts/redeploy-fleetd.sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1296,6 +1438,8 @@ test_report_supervisor_state_systemd_sets_supervised_without_log_path_check() {
|
|||||||
[ "$SUPERVISED" = 1 ] || fail "report_supervisor_state systemd must set SUPERVISED=1"
|
[ "$SUPERVISED" = 1 ] || fail "report_supervisor_state systemd must set SUPERVISED=1"
|
||||||
[ "$CHECK_LOG_PATH_CALLED" = 0 ] \
|
[ "$CHECK_LOG_PATH_CALLED" = 0 ] \
|
||||||
|| fail "report_supervisor_state systemd must NOT call check_log_path_matches_plist"
|
|| fail "report_supervisor_state systemd must NOT call check_log_path_matches_plist"
|
||||||
|
[ "$CHECK_JAR_PATH_CALLED" = 0 ] \
|
||||||
|
|| fail "report_supervisor_state systemd must NOT call check_jar_path_matches_plist"
|
||||||
source "$ROOT/scripts/redeploy-fleetd.sh"
|
source "$ROOT/scripts/redeploy-fleetd.sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2153,6 +2297,8 @@ test_assert_single_daemon_accepts_one_pid
|
|||||||
test_assert_single_daemon_rejects_two_pids
|
test_assert_single_daemon_rejects_two_pids
|
||||||
test_running_pid_excludes_self_matching_wrapper_shell
|
test_running_pid_excludes_self_matching_wrapper_shell
|
||||||
test_running_pid_finds_a_real_java_named_second_process
|
test_running_pid_finds_a_real_java_named_second_process
|
||||||
|
test_running_pid_finds_a_real_java_named_process_from_target_dir
|
||||||
|
test_running_pid_excludes_self_matching_wrapper_shell_naming_target_dir
|
||||||
test_running_pid_drops_a_pid_whose_comm_is_not_java
|
test_running_pid_drops_a_pid_whose_comm_is_not_java
|
||||||
test_running_pid_drops_a_pid_that_exited_before_the_comm_lookup
|
test_running_pid_drops_a_pid_that_exited_before_the_comm_lookup
|
||||||
test_running_pid_counts_a_pid_whose_comm_is_java
|
test_running_pid_counts_a_pid_whose_comm_is_java
|
||||||
@@ -2161,8 +2307,10 @@ test_jar_id_defaults_to_live_and_reports_explicit_path
|
|||||||
test_hash256_computes_a_real_sha256
|
test_hash256_computes_a_real_sha256
|
||||||
test_jar_id_reports_absent_for_missing_file
|
test_jar_id_reports_absent_for_missing_file
|
||||||
test_jar_id_reports_unhashable_when_no_hasher_on_path
|
test_jar_id_reports_unhashable_when_no_hasher_on_path
|
||||||
test_stage_built_jar_moves_off_live_path
|
test_report_jar_state_agrees_when_hashes_match
|
||||||
test_stage_built_jar_dies_when_build_produced_nothing
|
test_report_jar_state_warns_when_hashes_differ
|
||||||
|
test_report_jar_state_both_absent_is_not_a_mismatch
|
||||||
|
test_jar_and_build_jar_are_sourced_outside_target_and_differ
|
||||||
test_swap_staged_jar_moves_staged_onto_live
|
test_swap_staged_jar_moves_staged_onto_live
|
||||||
test_swap_staged_jar_dies_without_staged_file
|
test_swap_staged_jar_dies_without_staged_file
|
||||||
test_swap_staged_jar_dies_when_mv_fails
|
test_swap_staged_jar_dies_when_mv_fails
|
||||||
@@ -2204,6 +2352,8 @@ test_drain_confirmed_false_on_anything_else
|
|||||||
test_run_drain_gate_skips_prompt_when_not_required
|
test_run_drain_gate_skips_prompt_when_not_required
|
||||||
test_run_drain_gate_confirmed_reply_does_not_refuse
|
test_run_drain_gate_confirmed_reply_does_not_refuse
|
||||||
test_run_drain_gate_declined_reply_refuses
|
test_run_drain_gate_declined_reply_refuses
|
||||||
|
test_check_jar_path_matches_plist_agrees_ok
|
||||||
|
test_check_jar_path_matches_plist_mismatch_dies
|
||||||
test_report_supervisor_state_launchd_sets_supervised_and_checks_log_path
|
test_report_supervisor_state_launchd_sets_supervised_and_checks_log_path
|
||||||
test_report_supervisor_state_systemd_sets_supervised_without_log_path_check
|
test_report_supervisor_state_systemd_sets_supervised_without_log_path_check
|
||||||
test_report_supervisor_state_none_leaves_supervised_zero
|
test_report_supervisor_state_none_leaves_supervised_zero
|
||||||
|
|||||||
Reference in New Issue
Block a user