omp-conductor 0.18.1 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/doctor.ts CHANGED
@@ -46,9 +46,11 @@ import {
46
46
  dbBackupDirFor,
47
47
  findProject,
48
48
  loadConfig,
49
+ resolveArmProof,
49
50
  resolveCaps,
50
51
  stateDir,
51
52
  } from "./config.ts";
53
+ import { observeArmChallenge } from "./arm-challenge.ts";
52
54
  import { ompSettingsOverlay, sessionRootDir } from "./omp-settings.ts";
53
55
  import {
54
56
  DEFAULT_HERDR_UNIT,
@@ -85,10 +87,7 @@ import {
85
87
  SYSTEMD_UNIT_DIR,
86
88
  tickCwdForProject,
87
89
  totalConfiguredWorkers,
88
- workerAclHealth,
89
- type WorkerAclHealth,
90
90
  } from "./setup-host.ts";
91
- import { WORKER_ACCOUNT } from "./host.ts";
92
91
  import { checkTokenScopes, type ScopeCheck } from "./setup.ts";
93
92
  import { DB_SNAPSHOT_STEM, dbPath, LIVE_STATES } from "./store.ts";
94
93
  import { telegramReportSend, type ReportSend } from "./reports.ts";
@@ -101,7 +100,7 @@ import {
101
100
  type HerdrAgentList,
102
101
  } from "./orchestrator-tick.ts";
103
102
  import type { ConductorConfig, ProjectConfig, RepoTarget, RunState } from "./types.ts";
104
-
103
+ import { DEFAULT_ARM_PROOF, type ArmProof } from "./types.ts";
105
104
  /**
106
105
  * `doctor`'s finding vocabulary. `pass`/`warn`/`fail` — a warning renders the
107
106
  * same but never flips the exit code; exit is nonzero iff any finding fails.
@@ -256,8 +255,10 @@ export interface DoctorDeps {
256
255
  /** The topic id an arm challenge would send into for this project —
257
256
  * resolveProjectTopicId's pin-or-live-claim; undefined means flat chat. */
258
257
  armSendTopic?: (project: ProjectConfig) => number | undefined;
259
- /** The session directories arm scans for the challenge reply on this
260
- * project — tick-cwd-derived plus the live claim's own directory. */
258
+ /** The session surface the claim-only plumbing verdict judges for this
259
+ * project — tick-cwd-derived plus the live claim's own directory. The
260
+ * challenge proof reads conductor state instead (#614), so this feeds
261
+ * claim-only verdict checks only. */
261
262
  armScanDirs?: (project: ProjectConfig) => readonly string[];
262
263
  /** Whether a recorded claim or dm-owner pid is live, with omp-telegram's
263
264
  * topics.ts semantics (EPERM is dead). */
@@ -275,13 +276,6 @@ export interface DoctorDeps {
275
276
  tickAgentName?: (project: ProjectConfig) => string | undefined;
276
277
  /** Clock, so a run is deterministic in tests. */
277
278
  now?: () => number;
278
- /** The linked worker config paths' effective-ACL verdict for the dedicated
279
- * worker account (#835): `checkable` paths judged, `missing` the ones that
280
- * do not currently grant the worker's needed effective access. Read-only
281
- * and injectable so no test ever touches the host's ACLs; the production
282
- * wiring is the identity plan's own getfacl probe, so `doctor` and
283
- * `setup host` cannot disagree about an ACL. */
284
- workerAclHealth?: () => WorkerAclHealth;
285
279
  /** The one opt-in side effect: send one self-identified Telegram probe. */
286
280
  probeTelegram?: boolean;
287
281
  }
@@ -762,6 +756,12 @@ function unresolvableShell(unit: string): boolean {
762
756
  * state resolved at staging time, so a rendered value doctor cannot reproduce
763
757
  * is excluded from both sides rather than reported as a difference — an
764
758
  * unresolvable canonical is "cannot compare", never drift (#511).
759
+ *
760
+ * The service `PATH=` line has no such exemption: since #879 it is canonical
761
+ * on both sides (`defaultServiceRuntime` composes it from the resolved fleet
762
+ * binaries plus the standard system directories, never from the rendering
763
+ * process's environment), so a differing installed value is genuine drift and
764
+ * must fail here with the value named.
765
765
  */
766
766
  export function unitDrift(installed: string, canonical: string): { differences: string[] } {
767
767
  const want = canonical
@@ -916,36 +916,6 @@ function ownershipProbe(probes: Probes): Finding {
916
916
  return failFinding("systemd-ownership", summary, fix);
917
917
  }
918
918
 
919
- /**
920
- * The dedicated worker account's path ACLs (#835): every linked worker config
921
- * path must grant the worker its *effective* search/read permissions. The
922
- * installed OMP harness chmods its agent config dir back to 0700 on every
923
- * open, and a chmod rewrites the ACL mask — the named entry survives as
924
- * `user:omp-worker:--x #effective:---` while the worker's access silently
925
- * vanishes, which is exactly the incident this finding exists to catch
926
- * (`${INCIDENTS.workerAcl}`). The verdict comes from the identity plan's own
927
- * probe through the read-only {@link DoctorDeps.workerAclHealth} seam, so a
928
- * pass here is a pass the plan agrees with and a fail names the paths whose
929
- * mask is stripping the grant.
930
- */
931
- function workerAclProbeFinding(probes: Probes): Finding {
932
- const health = probes.workerAclHealth();
933
- if (health.checkable === 0) {
934
- return passFinding("worker-acl", "no linked worker config paths on this host yet — nothing to check");
935
- }
936
- if (health.missing.length === 0) {
937
- return passFinding(
938
- "worker-acl",
939
- `${health.checkable} linked worker config path(s) grant ${WORKER_ACCOUNT} effective search/read access`,
940
- );
941
- }
942
- return failFinding(
943
- "worker-acl",
944
- `the ${WORKER_ACCOUNT} account's ACL is not effective on ${health.missing.join(", ")} — a named entry the ACL mask strips is ` +
945
- "unreachable by the worker, and the next admitted worker dies on EACCES before connecting",
946
- "re-run `omp-conductor setup host`: the transaction re-applies the ACL grants (mask included) after the final fleet restart",
947
- );
948
- }
949
919
 
950
920
  /** IANA timezone in reporting config — an invalid zone silently mis-schedules
951
921
  * the availability window and the daily digest (#273). */
@@ -1173,91 +1143,43 @@ function herdrAgentNameProbe(probes: Probes, p: ProjectConfig): Finding {
1173
1143
  }
1174
1144
 
1175
1145
  /**
1176
- * #600 check — the orchestrator's claimed session file vs the directory arm
1177
- * scans.
1146
+ * #614 check — the state of this project's arming acknowledgement transaction.
1178
1147
  *
1179
- * A pane resumed from a session created elsewhere keeps the original transcript
1180
- * path (herdr pins the pane to it), so the claimed file can live outside the
1181
- * session directory the tick cwd implies. arm follows the claim, so the
1182
- * mismatch alone is handled but it is exactly the shape of host that once
1183
- * made arming silently impossible, and an operator reading transcript paths
1184
- * should not have to rediscover it. A claimed file *outside the session tree*
1185
- * is worse: arm refuses to arm there (the reply could never be seen), so
1186
- * doctor fails rather than letting the fleet sit disarmed.
1148
+ * The challenge proof is conductor-owned state now: the host records a pending
1149
+ * challenge and waits for the acknowledgement the orchestrator's inbound
1150
+ * adapter writes. No transcript location is diagnosed any more where (or
1151
+ * whether) a session file lives is not part of arming so what is worth
1152
+ * reporting is a transaction left open: a challenge still pending and whether
1153
+ * it has been acknowledged, or an expired record nothing settled. Every shape
1154
+ * is inert by construction (a reply past expiry, or for a replaced id, is
1155
+ * refused), so this informs; only the stale leftover warns.
1187
1156
  */
1188
- function armSessionDirProbe(probes: Probes, p: ProjectConfig): Finding {
1189
- const result = probes.claimedTopics();
1190
- if (result.kind !== "ok") {
1191
- // The registry could not be read, so the claim's session file is unknown,
1192
- // not known-absent. Arm scans the tick-cwd directory either way, but the
1193
- // finding must stay diagnostic — this is exactly the shape that once made
1194
- // arming silently impossible, and "no claim" would certify it as fine. A
1195
- // missing registry is the same unknown to this probe: the bridge may never
1196
- // have claimed, but nothing proves the claim names no session file.
1157
+ function armAckProbe(probes: Probes, p: ProjectConfig): Finding {
1158
+ const now = probes.now();
1159
+ const sighting = observeArmChallenge(p.name);
1160
+ if (sighting === undefined) {
1161
+ return passFinding("arm-ack", `[${p.name}] no pending arm challenge the acknowledgement handshake is idle`);
1162
+ }
1163
+ const window =
1164
+ sighting.sentAt === undefined || sighting.expiresAt === undefined
1165
+ ? "an unreadable record window"
1166
+ : `sent ${new Date(sighting.sentAt).toISOString()}, expires ${new Date(sighting.expiresAt).toISOString()}`;
1167
+ if (sighting.expiresAt !== undefined && now >= sighting.expiresAt) {
1197
1168
  return warnFinding(
1198
- "arm-session-dir",
1199
- `[${p.name}] ` +
1200
- (result.kind === "missing"
1201
- ? `omp-telegram has no claim registry (threads.json in its state dir) — arm scans the tick-cwd session directory, but doctor cannot tell whether the claim names a session file elsewhere`
1202
- : `omp-telegram's claim registry is unreadable (${result.problem}) — arm scans the tick-cwd session directory, but doctor cannot tell whether the claim names a session file elsewhere`),
1203
- `check omp-telegram's claim registry (threads.json in its state dir) is readable and the bridge is running, then re-run doctor`,
1169
+ "arm-ack",
1170
+ `[${p.name}] an arming challenge from ${window} expired without being settled — it is inert (replies past expiry are refused), but it lingers until this project's next arm replaces it`,
1171
+ `no action needed; the next \`arm\` for this project settles it — re-run doctor afterwards to confirm`,
1204
1172
  );
1205
1173
  }
1206
- if (result.claims.length === 0) {
1174
+ if (sighting.acknowledgedAt !== undefined) {
1207
1175
  return passFinding(
1208
- "arm-session-dir",
1209
- `[${p.name}] no live omp-telegram claim arm scans the tick-cwd session directory`,
1210
- );
1211
- }
1212
- const claims = result.claims;
1213
- const match = resolveProjectClaim(claims, p.name);
1214
- const scanDir = sessionDirForCwd(tickCwdForProject(p));
1215
- if (match.kind === "ambiguous") {
1216
- // Several live claims answer to the project, so the claim's session file
1217
- // cannot be told from a sibling pane's. Arm scans the tick-cwd directory
1218
- // either way, but certifying that fallback as the scan is the miss this
1219
- // probe exists to surface: the resumed transcript may live under another
1220
- // claim while doctor reads the fallback as the whole story (#626).
1221
- return warnFinding(
1222
- "arm-session-dir",
1223
- `[${p.name}] several live claims answer to this project (topics ${match.claimants.map((c) => c.threadId).join(", ")}), so the claim's session file is a coin toss — arm scans the tick-cwd session directory, but the resumed transcript may live elsewhere`,
1224
- `make exactly one live claim answer to this project: rename the other panes' herdr spaces or re-claim them one at a time, then re-run doctor — or start the orchestrator pane's session from the tick cwd ${scanDir}`,
1225
- );
1226
- }
1227
- const claim = match.kind === "match" ? match.claim : undefined;
1228
- if (claim === undefined) {
1229
- return passFinding(
1230
- "arm-session-dir",
1231
- `[${p.name}] no live omp-telegram claim answers to this project — arm scans the tick-cwd session directory`,
1232
- );
1233
- }
1234
- if (claim.sessionFile === undefined) {
1235
- return passFinding(
1236
- "arm-session-dir",
1237
- `[${p.name}] the live claim names no session file — arm scans the tick-cwd session directory`,
1238
- );
1239
- }
1240
- const root = sessionsRoot();
1241
- const claimDir = dirname(claim.sessionFile);
1242
- if (claimDir !== root && !claimDir.startsWith(join(root, sep))) {
1243
- return failFinding(
1244
- "arm-session-dir",
1245
- `[${p.name}] the orchestrator's claimed session file ${claim.sessionFile} is outside the session tree arm scans (${root}) — ` +
1246
- `arm cannot arm while the reply can only land there`,
1247
- `start the orchestrator pane's session under ${root}, or from the tick cwd ${scanDir}`,
1248
- );
1249
- }
1250
- if (claimDir !== scanDir) {
1251
- return warnFinding(
1252
- "arm-session-dir",
1253
- `[${p.name}] the orchestrator's claimed session file ${claim.sessionFile} lives in ${claimDir}, not the tick-cwd session directory ${scanDir} — ` +
1254
- `arm follows the claim, so arming works, but this is the resumed-pane shape #600 names`,
1255
- `optional tidy-up: start the pane's session from the tick cwd so transcript and cwd agree again`,
1176
+ "arm-ack",
1177
+ `[${p.name}] an arm challenge is pending (${window}) and was acknowledged ${new Date(sighting.acknowledgedAt).toISOString()} — the host consumes it on settle`,
1256
1178
  );
1257
1179
  }
1258
1180
  return passFinding(
1259
- "arm-session-dir",
1260
- `[${p.name}] the orchestrator's claimed session file is inside the session directory arm scans`,
1181
+ "arm-ack",
1182
+ `[${p.name}] an arm challenge is pending (${window}), not yet acknowledged live if \`arm\` is running right now, otherwise its window simply runs out`,
1261
1183
  );
1262
1184
  }
1263
1185
 
@@ -1379,9 +1301,20 @@ function telegramPlumbingProbe(probes: Probes, p: ProjectConfig): Finding {
1379
1301
  `[${p.name}] outbound-only paging — no tick accessFile to arm, so no live claim, DM owner or poll ownership is required`,
1380
1302
  );
1381
1303
  }
1304
+ // Transcript-surface identity checks belong to the claim-only proof alone:
1305
+ // since #614 the challenge proof reads conductor acknowledgement state, so
1306
+ // judging it by where a transcript lives could report arming impossible
1307
+ // while `arm` succeeds. An unreadable config keeps the default proof —
1308
+ // fail-safe to the stricter verdict, exactly like armTicks.
1309
+ let proof: ArmProof = DEFAULT_ARM_PROOF;
1310
+ try {
1311
+ proof = resolveArmProof(p);
1312
+ } catch {
1313
+ /* unreadable config — keep today's stricter default */
1314
+ }
1382
1315
  const verdict = telegramPlumbingVerdict(
1383
1316
  probes.armSendTopic(p),
1384
- { dirs: probes.armScanDirs(p) },
1317
+ proof === "claim-only" ? { dirs: probes.armScanDirs(p) } : undefined,
1385
1318
  {
1386
1319
  channel: probes.channelState(p),
1387
1320
  registry: probes.claimedTopics(),
@@ -1663,7 +1596,7 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1663
1596
  passFinding("herdr-agent-name", projectProblem === undefined ? "no project resolved — nothing to check" : `agent name uncheckable: ${projectProblem}`),
1664
1597
  );
1665
1598
  findings.push(
1666
- passFinding("arm-session-dir", projectProblem === undefined ? "no project resolved — nothing to check" : `arm session dir uncheckable: ${projectProblem}`),
1599
+ passFinding("arm-ack", projectProblem === undefined ? "no project resolved — nothing to check" : `arm acknowledgement uncheckable: ${projectProblem}`),
1667
1600
  );
1668
1601
  findings.push(
1669
1602
  passFinding("topic-pin", projectProblem === undefined ? "no project resolved — nothing to check" : `topic pin uncheckable: ${projectProblem}`),
@@ -1678,7 +1611,7 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1678
1611
  for (const p of projects) {
1679
1612
  findings.push(await labelProbe(probes, p));
1680
1613
  findings.push(herdrAgentNameProbe(probes, p));
1681
- findings.push(armSessionDirProbe(probes, p));
1614
+ findings.push(armAckProbe(probes, p));
1682
1615
  findings.push(topicPinProbe(probes, p));
1683
1616
  findings.push(telegramPlumbingProbe(probes, p));
1684
1617
  findings.push(ompSettingsProbe(p, sessionRootState));
@@ -1690,10 +1623,6 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1690
1623
  findings.push(unitProbe(probes, projects[0], cfg));
1691
1624
  findings.push(recoveryProbe(probes));
1692
1625
  findings.push(ownershipProbe(probes));
1693
- // The worker's path ACLs are a host-wide fact like the unit ownership: one
1694
- // shared daemon account, one shared worker identity, one shared set of
1695
- // linked config paths. A finding once, never once per project.
1696
- findings.push(workerAclProbeFinding(probes));
1697
1626
  // #541 seam checks, host-global: the live herdr config and the plugin's
1698
1627
  // config.env are single files on the host, not per-project facts.
1699
1628
  findings.push(herdrResumeProbe(probes));
@@ -1737,9 +1666,6 @@ export function defaultProbes(): Probes {
1737
1666
  readUnit: defaultReadUnit,
1738
1667
  stat: defaultStat,
1739
1668
  uidOf: defaultUidOf,
1740
- // The same effective-ACL read the identity plan plans with — doctor and
1741
- // setup cannot disagree about a worker grant (#835).
1742
- workerAclHealth: () => workerAclHealth(join(homedir(), ".omp", "agent")),
1743
1669
  dbIntegrity: defaultDbIntegrity,
1744
1670
  snapshotDirState: defaultSnapshotDirState,
1745
1671
  sessionRootState: defaultSessionRootState,
package/src/escalate.ts CHANGED
@@ -1040,13 +1040,14 @@ export interface TelegramPlumbingProbe {
1040
1040
  }
1041
1041
 
1042
1042
  /**
1043
- * The session surface the arm waiter actually scans for the challenge reply:
1044
- * {@link armTicks} polls every `.jsonl` transcript *directly inside* these
1045
- * directories the tick-cwd-derived session directory plus, when the
1046
- * project's claim names a session file elsewhere, that file's directory. The
1047
- * recipient of the reply (a claim's, the DM owner's, or the local poll
1048
- * owner's session file) must land in this set, or the reply can never satisfy
1049
- * the challenge even though inbound works.
1043
+ * The transcript-surface input for {@link telegramPlumbingVerdict}'s session
1044
+ * identity checks: the directories the claim-only proof judges recipients
1045
+ * against (a claim's, the DM owner's, or the local poll owner's session file
1046
+ * must land inside, or a flat reply could land where no challenge would have
1047
+ * looked). Since #614 the *challenge* proof reads conductor acknowledgement
1048
+ * state and never opens a transcript, so callers judging challenge-mode
1049
+ * plumbing omit this passing it there could report arming impossible while
1050
+ * `arm` succeeds.
1050
1051
  */
1051
1052
  export type TelegramPlumbingScan = { dirs: readonly string[] };
1052
1053
 
@@ -1074,7 +1075,7 @@ export type TelegramPlumbingScan = { dirs: readonly string[] };
1074
1075
  */
1075
1076
  export function telegramPlumbingVerdict(
1076
1077
  sendTopic: number | undefined,
1077
- scan: TelegramPlumbingScan,
1078
+ scan: TelegramPlumbingScan | undefined,
1078
1079
  probe: TelegramPlumbingProbe,
1079
1080
  ): TelegramPlumbingVerdict {
1080
1081
  const { registry, poll, dmOwner, channel, alive, lockAlive, fresh } = probe;
@@ -1085,14 +1086,21 @@ export function telegramPlumbingVerdict(
1085
1086
  const claim = registry.claims.find((c) => c.threadId === sendTopic);
1086
1087
  if (claim === undefined) return { ok: false, reason: "no-topic-claim" };
1087
1088
  if (claim.pid === undefined || !alive(claim.pid)) return { ok: false, reason: "claim-dead" };
1088
- if (claim.sessionFile !== undefined && !sessionWithinScan(claim.sessionFile, scan.dirs)) {
1089
+ if (
1090
+ scan !== undefined &&
1091
+ claim.sessionFile !== undefined &&
1092
+ !sessionWithinScan(claim.sessionFile, scan.dirs)
1093
+ ) {
1089
1094
  return { ok: false, reason: "claim-session-outside" };
1090
1095
  }
1091
1096
  recipient = "claim";
1092
1097
  } else {
1093
1098
  if (dmOwner.kind === "ok") {
1094
1099
  if (dmOwner.owner.pid === undefined || !alive(dmOwner.owner.pid)) return { ok: false, reason: "dm-owner-dead" };
1095
- if (dmOwner.owner.sessionFile === undefined || !sessionWithinScan(dmOwner.owner.sessionFile, scan.dirs)) {
1100
+ if (
1101
+ scan !== undefined &&
1102
+ (dmOwner.owner.sessionFile === undefined || !sessionWithinScan(dmOwner.owner.sessionFile, scan.dirs))
1103
+ ) {
1096
1104
  return { ok: false, reason: "dm-owner-unrelated" };
1097
1105
  }
1098
1106
  recipient = "dm-owner";
@@ -1102,7 +1110,10 @@ export function telegramPlumbingVerdict(
1102
1110
  // record must name that session. A daemon-only or unrelated owner is no
1103
1111
  // recipient: the reply would land in a transcript arm never scans.
1104
1112
  if (poll.kind !== "ok") return { ok: false, reason: "no-dm-owner" };
1105
- if (poll.owner.sessionFile === undefined || !sessionWithinScan(poll.owner.sessionFile, scan.dirs)) {
1113
+ if (
1114
+ scan !== undefined &&
1115
+ (poll.owner.sessionFile === undefined || !sessionWithinScan(poll.owner.sessionFile, scan.dirs))
1116
+ ) {
1106
1117
  return { ok: false, reason: "no-dm-owner" };
1107
1118
  }
1108
1119
  recipient = "poller";