omp-conductor 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -204,6 +204,26 @@ Also required on the host:
204
204
  unconfigured rather than guessed at, on the grounds that a health row which
205
205
  reads green over a broken contract is worse than one that overstates a fault.
206
206
 
207
+ - **A fleet that answers instead of narrating** needs one key, set once:
208
+ `/telegram set profile daemon` (omp-telegram 0.11.0 or newer). Without it the
209
+ bridge behaves as it does on a laptop: it finalizes a real Telegram message
210
+ per assistant turn for as long as a conversation is active — so one answer
211
+ arrives as several messages, and a message that lands mid-tick keeps relaying
212
+ that tick's internal turns — and it posts every local run's closing text to
213
+ `notifyChat`, which on a host whose runs are heartbeat ticks means each tick's
214
+ working prose. The profile switches all of it off at the transport: text
215
+ reaches Telegram only through `telegram_send` / `telegram_ask`, the idle post
216
+ is suppressed, and `telegram_ask` stays mounted and aimed at the paired owner
217
+ on every turn — including a locally injected tick, so it also removes the need
218
+ for `notifyMode` above. Approval and blocked-input pings still fire; those
219
+ mean a human is needed, which is the point of the channel.
220
+
221
+ `omp-conductor status` reports an interactive profile on the `telegram` row,
222
+ and every tick composed on one carries a prompt line saying so. Note the two
223
+ settings pull against each other before the profile exists: setting
224
+ `notifyMode` to make `telegram_ask` mountable is exactly what arms the idle
225
+ post, so the correctly askable fleet was also the loud one.
226
+
207
227
  With neither, tier 2 degrades to a comment on the issue. Nothing is broken in
208
228
  that configuration: it is supported, just slower to reach you.
209
229
 
@@ -2166,8 +2186,9 @@ refuses a connection from anything else without answering it, logged the way an
2166
2186
  impersonation is. Until a channel is bound it refuses everything, because the
2167
2187
  socket necessarily exists before the child that connects to it. The kernel
2168
2188
  supplies the pid: `SO_PEERCRED` on Linux, `LOCAL_PEERPID` on macOS. A host where
2169
- neither can be asked refuses every connection and says so at startup rather than
2170
- falling back to the uid, which would be no check at all.
2189
+ neither can be asked no loadable libc, or the call refused refuses every
2190
+ connection on a bound channel and says so at startup, rather than falling back to
2191
+ the uid, which under one shared uid is no check at all.
2171
2192
 
2172
2193
  The residual is narrow, real, and worth stating: one uid can `ptrace` and signal
2173
2194
  its siblings, so a determined session can still interfere with the process that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -33,6 +33,17 @@
33
33
  * The legacy `away: true` boolean counts: `loadAccess()` migrates it to
34
34
  * `notifyMode: "away"` on read, so a fleet still carrying it resolves a target
35
35
  * and must not be reported as broken.
36
+ *
37
+ * Since omp-telegram 0.11.0 there is a second way for a locally injected turn to
38
+ * resolve a target, and it counts here for the same reason the legacy boolean
39
+ * does: `profile: "daemon"` makes `notifyTarget()` resolve without `notifyMode`
40
+ * at all, because the profile *is* the headless contract — explicit-only
41
+ * outbound, no idle notify post, and `telegram_ask` targeted at the paired owner
42
+ * on every turn including a cron tick. A fleet carrying it has the surface #114
43
+ * was about, so demanding `notifyMode` beside it would be the false alarm this
44
+ * file exists to avoid. The `notifyMode` paths are all still live: an older
45
+ * plugin ignores the `profile` key entirely, and a host running one keeps
46
+ * working unchanged.
36
47
  */
37
48
 
38
49
  import { readFileSync } from "node:fs";
@@ -46,6 +57,16 @@ export const TELEGRAM_APPROVAL_TOOL = "telegram_ask";
46
57
 
47
58
  export type ApprovalSurface = { kind: "ready" } | { kind: "missing"; reason: string };
48
59
 
60
+ /** Whether the Telegram bridge is running the headless contract, or merely
61
+ * configured well enough to answer a question.
62
+ *
63
+ * Two questions, not one, which is why this is a separate answer from
64
+ * {@link ApprovalSurface}: a fleet can be perfectly *askable* and still be
65
+ * narrating every turn into its operator's chat. `interactive` carries the
66
+ * reason because both readers of it — a tick prompt and a status row — have to
67
+ * name the remedy, and one spelling of it keeps them in agreement. */
68
+ export type DaemonProfileSurface = { kind: "daemon" } | { kind: "interactive"; reason: string };
69
+
49
70
  /** One checked read of a JSON property, so nothing below asserts a shape the
50
71
  * parse never proved. Anything that is not a plain object, or a key that is
51
72
  * absent, answers undefined — which every caller here already treats as
@@ -156,7 +177,20 @@ export function readApprovalSurface(path: string): ApprovalSurface {
156
177
  };
157
178
  }
158
179
  const mode = field(access, "notifyMode");
159
- const active = mode === "away" || mode === "always" || field(access, "away") === true;
180
+ // Three spellings of the same fact, and none of them is redundant.
181
+ // `notifyMode` is what an interactive host sets; `away: true` is the retired
182
+ // boolean `loadAccess()` migrates on read; `profile: "daemon"` is the headless
183
+ // contract, which omp-telegram ≥ 0.11.0 treats as its own reason to resolve a
184
+ // notify target (`notifyTarget()` gates on `notifyMode || profile === "daemon"`
185
+ // and `before_agent_start` mounts `telegram_ask` on the same disjunction). A
186
+ // daemon-profile fleet with no `notifyMode` is therefore *more* answerable than
187
+ // the configuration #114 asked for, not less, and reporting it broken would be
188
+ // the every-interval false alarm this file was written to prevent.
189
+ const active =
190
+ mode === "away" ||
191
+ mode === "always" ||
192
+ field(access, "away") === true ||
193
+ field(access, "profile") === "daemon";
160
194
  if (!active) {
161
195
  return {
162
196
  kind: "missing",
@@ -251,3 +285,58 @@ export function readApprovalSurface(path: string): ApprovalSurface {
251
285
  }
252
286
  return { kind: "ready" };
253
287
  }
288
+
289
+ /**
290
+ * Reads the same access file and answers the other question: does visible
291
+ * assistant text stay out of the operator's chat?
292
+ *
293
+ * A separate read rather than a field on {@link ApprovalSurface}, because the
294
+ * two faults have different remedies and a caller may care about only one. This
295
+ * one is about *noise*: without the profile, omp-telegram relays a real Telegram
296
+ * message per assistant turn for as long as a conversation is marked active
297
+ * (`outbound.ts` `onTurnEnd`), and — because the fleet sets `notifyMode` for the
298
+ * approval surface above — posts every local run's closing text to the notify
299
+ * chat from its `agent_end` handler. So a correctly *askable* fleet is exactly
300
+ * the one that narrates: the two settings pull against each other, which is why
301
+ * both are checked and only the profile silences the second.
302
+ *
303
+ * One key decides it, deliberately. `profile: "daemon"` is omp-telegram ≥ 0.11.0's
304
+ * whole headless contract — explicit-only outbound, no idle/final notify post,
305
+ * `telegram_ask` targeted at the paired owner on every turn — so verifying it is
306
+ * one comparison rather than three inferences about `streaming`, `notifyMode` and
307
+ * turn origin that would drift the moment the plugin changes. Anything other
308
+ * than the literal string is `interactive`: `loadAccess()` drops every other
309
+ * value to undefined, so `"DAEMON"` and `5` are not the contract on the host
310
+ * either, and reporting them as one would promise a suppression nothing applied.
311
+ *
312
+ * Never throws, and never writes: conductor reads this file, the operator's
313
+ * `/telegram set profile daemon` is what changes it. An unreadable or corrupt
314
+ * file answers `interactive` for the reason the whole module fails closed — a
315
+ * green signal over an unproven contract is what #114 was.
316
+ */
317
+ export function readDaemonProfile(accessPath: string): DaemonProfileSurface {
318
+ const remedy = "assistant text can auto-relay to Telegram — run `/telegram set profile daemon`";
319
+ let access: unknown;
320
+ try {
321
+ access = JSON.parse(readFileSync(accessPath, "utf8"));
322
+ } catch {
323
+ return {
324
+ kind: "interactive",
325
+ reason: `cannot read or parse ${accessPath}, so no telegram profile is proven; ${remedy}`,
326
+ };
327
+ }
328
+ if (access === null || typeof access !== "object" || Array.isArray(access)) {
329
+ return {
330
+ kind: "interactive",
331
+ reason: `${accessPath} is not an object, so no telegram profile is proven; ${remedy}`,
332
+ };
333
+ }
334
+ const profile = field(access, "profile");
335
+ if (profile === "daemon") return { kind: "daemon" };
336
+ // An absent key reads as "default" rather than "undefined": that is the word
337
+ // the plugin's own `/telegram status` line and its `profile: daemon | default`
338
+ // help text use, and an operator matching a status row against this reason
339
+ // should not have to translate.
340
+ const observed = profile === undefined ? "default" : JSON.stringify(profile);
341
+ return { kind: "interactive", reason: `telegram profile is ${observed}; ${remedy}` };
342
+ }
@@ -208,18 +208,23 @@ sent that way is undetectable when it does not arrive.
208
208
 
209
209
  ## Human messages
210
210
 
211
- A human writing to you between ticks is not a tick. Answer the question they
212
- actually asked, in one message, from evidence you already hold or go and fetch.
213
-
214
- Then stop. Do not continue loop narration in the same reply, and do not restate
215
- in-progress work unless they asked for it. The loop resumes on the next tick.
211
+ A human writing to you between ticks is not a tick. Answer with a **single
212
+ `telegram_send` call** one message, the answer only, from evidence you already
213
+ hold or go and fetch. Never answer as plain end-of-turn text: on this session,
214
+ text you merely write reaches nobody if you do not call `telegram_send`, the
215
+ person gets silence. While handling any turn, produce no visible commentary
216
+ between tool calls — reasoning stays in thinking, actions stay in tools.
217
+
218
+ If the answer needs a decision from the operator (a choice, a yes/no, an
219
+ approval), ask it with `telegram_ask` — never a numbered-options message via
220
+ `telegram_send`, and never the generic `ask` UI. A cancelled or errored
221
+ `telegram_ask` is a delivery failure, not an answer.
216
222
 
217
223
  A message may also reach you **mid-tick** (delivery is steering: it arrives
218
224
  between two of your tool calls). Treat it as an interrupt, not a new tick:
219
- answer it immediately with `telegram_send` in one message, then return to the
220
- duty you were in the middle of and finish it. Never abandon or restart the tick
221
- because a message arrived, and never batch the answer "for the report" — the
222
- person is waiting now.
225
+ `telegram_send` the answer immediately, then return to the duty you were in the
226
+ middle of and finish it. Never abandon or restart the tick because a message
227
+ arrived, and never batch the answer "for the report" — the person is waiting now.
223
228
 
224
229
  ## Escalation tiers
225
230
 
@@ -74,10 +74,11 @@ Your report scope is **`{{REPORT_SCOPE}}`**. All three scopes, spelled out:
74
74
  issue you pulled off the queue, a cap that stopped the fleet. A tick where
75
75
  nothing changed still says nothing — "no change" is not an event.
76
76
 
77
- **Delivery.** Your end-of-turn text reaches your operator only on a turn that
78
- *began* as an inbound Telegram message. A tick did not: it is injected locally,
79
- so a report you merely write at the end of one is read by nobody, however well
80
- you wrote it. Hand every reportable event to the conductor's outbox instead:
77
+ **Delivery.** Never rely on end-of-turn text reaching anyone. The only delivery
78
+ paths are `omp-conductor report` (reports persisted, daemon-retried),
79
+ `telegram_send` (a person who is waiting), and `telegram_ask` (a decision).
80
+ Everything else is noise or silence. Hand every reportable event to the
81
+ conductor's outbox:
81
82
 
82
83
  ```
83
84
  omp-conductor report --text "<the whole report>" # a material event
package/src/fleet.ts CHANGED
@@ -29,7 +29,7 @@ import { homedir } from "node:os";
29
29
  import { dirname, join } from "node:path";
30
30
  import { findProject, loadConfig, stateDir } from "./config.ts";
31
31
  import { planUsageLine, readPlanUsage, sharedUsageSource } from "./usage.ts";
32
- import { readApprovalSurface } from "./approval-surface.ts";
32
+ import { readApprovalSurface, readDaemonProfile } from "./approval-surface.ts";
33
33
  import { inspectBriefLayout } from "./brief-upgrade.ts";
34
34
  import { dbPath, openStore } from "./store.ts";
35
35
  import { renderBriefForProject } from "./setup.ts";
@@ -1303,6 +1303,24 @@ export async function probeTelegramHealth(
1303
1303
  if (approval.kind === "missing") {
1304
1304
  return { kind: "degraded", detail: `${username}; inbound configured; ${approval.reason}` };
1305
1305
  }
1306
+ // Last, and only once the surface is answerable: whether it is *quiet*.
1307
+ //
1308
+ // The order is the order an operator should fix things in, and one row carries
1309
+ // one remedy. A missing token means nothing outbound works, so the approval
1310
+ // surface is not worth discussing; a missing approval surface means an
1311
+ // amendment cannot be asked, which outranks noise; an interactive profile is
1312
+ // real but strictly the least severe — the fleet works, it is just loud. And
1313
+ // the two lower checks are not independent of each other: setting `notifyMode`
1314
+ // to fix the approval surface is what *arms* the `agent_end` notify post this
1315
+ // one warns about, so a fleet that fixes them in the other order would see the
1316
+ // noise appear as the reward for fixing the silence.
1317
+ const profile = readDaemonProfile(accessPath);
1318
+ if (profile.kind === "interactive") {
1319
+ return {
1320
+ kind: "degraded",
1321
+ detail: `${username}; inbound configured; interactive profile relays assistant text — /telegram set profile daemon`,
1322
+ };
1323
+ }
1306
1324
  return { kind: "ok", detail: `${username}; inbound configured; telegram_ask available` };
1307
1325
  }
1308
1326
 
package/src/omp.ts CHANGED
@@ -361,6 +361,13 @@ const START_TIMEOUT_MS = 180_000;
361
361
  /** Bounded tail of the child's stderr, so a crash is legible without unbounded buffering. */
362
362
  const STDERR_TAIL = 8_000;
363
363
 
364
+ /**
365
+ * How long a dying child's pipes get to finish before its failure is reported
366
+ * without them. Generous because it only ever delays an already-failed startup,
367
+ * and the streams normally reach EOF the instant the process does.
368
+ */
369
+ const DRAIN_GRACE_MS = 2_000;
370
+
364
371
  /** How long a disposed child gets to exit before it is signalled. */
365
372
  const DISPOSE_GRACE_MS = 5_000;
366
373
 
@@ -511,8 +518,35 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
511
518
  }
512
519
  }
513
520
  };
514
- void drain(child.stdout, "session: ");
515
- void drain(child.stderr, "session: ");
521
+ // Kept, not discarded: the child's own stderr is the whole value of the startup
522
+ // failure path, and it is a *race* against `child.exited` rather than a
523
+ // guarantee. A child that dies before connecting resolves `exited` as soon as
524
+ // the process is gone, which can be before these loops have read what it
525
+ // printed on the way out — and then the operator gets "exited 1" instead of the
526
+ // reason, which is precisely the diagnosis this path exists to give. Observed
527
+ // failing on ubuntu and passing on darwin, which is what a race looks like.
528
+ const drained = Promise.all([drain(child.stdout, "session: "), drain(child.stderr, "session: ")]);
529
+ drained.catch(() => undefined);
530
+
531
+ /**
532
+ * The child's output, once the pipes have actually finished — the whole point of
533
+ * every failure path below.
534
+ *
535
+ * Bounded rather than awaited outright: a grandchild that inherited stderr keeps
536
+ * the pipe open after the child is gone, and a failure nobody reports is worse
537
+ * than one reported without its tail. Both exit routes go through here so they
538
+ * cannot drift apart again; the pre-connect one did, which is how a release run
539
+ * caught this.
540
+ */
541
+ const settledTail = async (): Promise<string> => {
542
+ await Promise.race([
543
+ drained,
544
+ new Promise<void>((resolve) => {
545
+ setTimeout(resolve, DRAIN_GRACE_MS).unref?.();
546
+ }),
547
+ ]);
548
+ return stderrTail.trim();
549
+ };
516
550
 
517
551
  const cleanup = (): void => {
518
552
  server.close();
@@ -544,7 +578,7 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
544
578
  pending.clear();
545
579
  };
546
580
 
547
- void child.exited.then((code) => {
581
+ void child.exited.then(async (code) => {
548
582
  onExit();
549
583
  cleanup();
550
584
  // A child that exits during teardown exited because we asked it to. Only an
@@ -552,6 +586,8 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
552
586
  // to reach whoever is awaiting a prompt, or the dispatcher waits forever
553
587
  // for a turn from a process that is gone.
554
588
  if (disposing) return;
589
+ // The tail first, so the message carries the child's own words.
590
+ await settledTail();
555
591
  fail(`omp-conductor session child exited ${String(code)} before the session ended`);
556
592
  });
557
593
 
@@ -577,7 +613,11 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
577
613
  } catch (err) {
578
614
  child.kill("SIGKILL");
579
615
  cleanup();
580
- const tail = stderrTail.trim();
616
+ // Killed first, so the pipes are already closing, and only then read. This is
617
+ // the route a child that dies before connecting takes — a missing peer
618
+ // dependency, say — and reading `stderrTail` synchronously here raced the
619
+ // drain loops and reported a bare exit code instead of the reason.
620
+ const tail = await settledTail();
581
621
  throw new Error(
582
622
  `${err instanceof Error ? err.message : String(err)}${tail === "" ? "" : `\nchild output:\n${tail}`}`,
583
623
  );
@@ -52,6 +52,7 @@ import {
52
52
  bridgeTokenBound,
53
53
  hasBotToken,
54
54
  readApprovalSurface,
55
+ readDaemonProfile,
55
56
  TELEGRAM_APPROVAL_TOOL,
56
57
  } from "./approval-surface.ts";
57
58
  import {
@@ -363,12 +364,27 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
363
364
  /**
364
365
  * The delivery clause, appended to every default tick prompt.
365
366
  *
366
- * End-of-turn text streams to the operator's Telegram only on a turn that
367
- * *began* as an inbound Telegram message. A heartbeat tick is injected locally,
368
- * so it is never such a turn, and a session that believes otherwise reports
369
- * into a void: on 2026-08-06 the fleet this extension runs produced a release
370
- * report and two tier-2 escalations as end-of-turn text, and not one of the
371
- * three reached anybody.
367
+ * A tick's end-of-turn text does not reach the operator as a *report*, and the
368
+ * rule's job is to stop a session believing otherwise: on 2026-08-06 the fleet
369
+ * this extension runs produced a release report and two tier-2 escalations as
370
+ * end-of-turn text, and not one of the three reached anybody.
371
+ *
372
+ * This comment used to explain that by saying end-of-turn text streams to
373
+ * Telegram only on a turn that *began* as an inbound Telegram message. That
374
+ * premise is false, and the correction matters because it flips the reason:
375
+ * omp-telegram's `agent_end` handler posts `finalText` to the notify chat on
376
+ * every run that did *not* come from Telegram, whenever `notifyMode` is set —
377
+ * and the fleet must set it, because {@link readApprovalSurface} needs it for
378
+ * the approval surface. So a tick's closing prose *was* reaching the operator
379
+ * all along: not as a delivered report, as an unlogged, unretried, untracked
380
+ * ping in the middle of whatever else was in that chat. Both the void and the
381
+ * ping are the same fault seen from two sides — text is not a delivery channel.
382
+ *
383
+ * omp-telegram 0.11.0's `profile: "daemon"` removes it in the other direction:
384
+ * explicit-only outbound, and the idle/final notify post suppressed outright, so
385
+ * a tick's visible text goes nowhere at all. That is the configuration the fleet
386
+ * runs (see {@link TICK_NARRATION_RULE}, which warns when it is absent), and it
387
+ * makes this rule's demand literal rather than merely prudent.
372
388
  *
373
389
  * The clause used to name `telegram_send`, and that held — but it was still an
374
390
  * instruction where a mechanism was needed: the same miss recurs whenever the
@@ -418,6 +434,38 @@ export const TICK_APPROVAL_UNAVAILABLE_RULE =
418
434
  `If you have an amendment to propose, deliver the question with telegram_send and wait for your operator's reply on a later turn; ` +
419
435
  `never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
420
436
 
437
+ /**
438
+ * Appended to every tick — the shipped prompt or the operator's own — composed
439
+ * on a bridge that is not in omp-telegram's `profile: "daemon"`.
440
+ *
441
+ * The running commentary an operator sees is *mechanical*, not disobedience, and
442
+ * that is why a prompt line is the wrong permanent fix and the right interim one.
443
+ * Two verified paths carry visible text out without anyone calling a tool:
444
+ * `outbound.ts` `onTurnEnd()` finalizes one real Telegram message per assistant
445
+ * turn for as long as the chat is marked active — so a multi-step answer arrives
446
+ * as several messages, and a message that lands mid-tick keeps the chat active
447
+ * for the rest of that run, relaying every subsequent tick-internal turn — and
448
+ * the `agent_end` handler posts the closing text of every *local* run to the
449
+ * notify chat whenever `notifyMode` is set. The fleet has to set `notifyMode`
450
+ * (see {@link TICK_APPROVAL_UNAVAILABLE_RULE}), so the correctly-askable fleet is
451
+ * exactly the narrating one. `profile: "daemon"` closes both at the transport:
452
+ * explicit-only outbound, notify post suppressed.
453
+ *
454
+ * So this rule is a stopgap with an expiry date, and it says so: it names the
455
+ * one command that removes it. Until then the only defence is discipline the
456
+ * turn can actually exercise — no visible text between tool calls, and one
457
+ * `telegram_send` for a human answer rather than prose that leaks a turn at a
458
+ * time. It fires *because* the mechanism is absent, so a fleet that has run
459
+ * `/telegram set profile daemon` never sees it.
460
+ *
461
+ * Appended to a configured `message` too, for {@link TICK_APPROVAL_UNAVAILABLE_RULE}'s
462
+ * reason exactly: an operator's prompt owns the reporting contract, but it cannot
463
+ * consent on the orchestrator's behalf to what the transport does with its text.
464
+ * Transport truth is not the operator's prompt to waive.
465
+ */
466
+ export const TICK_NARRATION_RULE =
467
+ "This session's Telegram bridge is NOT in daemon profile, so visible assistant text can auto-relay to your operator's chat (per-turn messages while a Telegram conversation is active, and end-of-run notify posts on local runs). Until the operator runs `/telegram set profile daemon`: produce no visible text between tool calls, and answer any human message with a single telegram_send call only.";
468
+
421
469
  function frictionLabel(kind: FrictionSignal["kind"]): string {
422
470
  if (kind.startsWith("admission:")) return `admission hold ${kind.slice("admission:".length)}`;
423
471
  if (kind === "feedback:escalation-should-digest") return "escalations classified as digest material";
@@ -1254,6 +1302,28 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1254
1302
  }
1255
1303
  }
1256
1304
 
1305
+ // The other half of transport truth, read from the same file at the same
1306
+ // moment: whether visible text stays out of the operator's chat. Appended
1307
+ // after the approval line because it is the weaker instruction of the two —
1308
+ // the approval rule stops an invented approval, this one asks the turn to keep
1309
+ // quiet — and a prompt should end on the sentence that must not be missed.
1310
+ //
1311
+ // Gated on `bridgeTokenAtStart` for the same reason the approval read is, and
1312
+ // the reasoning is the mirror image of it: with no token bound, omp-telegram
1313
+ // sends *nothing*, so the two relay paths this rule warns about cannot fire
1314
+ // either. A narration warning on a dead bridge would describe a hazard that
1315
+ // does not exist, on top of an approval warning that already names the one
1316
+ // command worth running (`/telegram on`) — two remedies on one prompt, and the
1317
+ // operator acts on neither. So the rule fires only where the leak is real: a
1318
+ // live bridge whose profile is not `daemon`.
1319
+ //
1320
+ // No `accessFile` means no fleet bridge to judge, exactly as above.
1321
+ const profile =
1322
+ config.accessFile === undefined || !session.bridgeTokenAtStart
1323
+ ? undefined
1324
+ : readDaemonProfile(config.accessFile);
1325
+ if (profile?.kind === "interactive") content = `${content}\n${TICK_NARRATION_RULE}`;
1326
+
1257
1327
  try {
1258
1328
  pi.sendMessage(
1259
1329
  { customType: TICK_CUSTOM_TYPE, content, display: true, attribution: "user" },
@@ -413,10 +413,29 @@ export function peerVerdict(
413
413
  };
414
414
  }
415
415
  if (peer === undefined) {
416
+ // A channel bound to a process and no way to identify the caller is the one
417
+ // combination that must never pass. Under one uid the mode says nothing about
418
+ // *which* session is connecting, so allowing here would hand any session the
419
+ // authority of whichever channel it reached — on exactly the hosts where the
420
+ // check silently could not run. This ordering was the bug: the previous
421
+ // release returned `socket-ownership` here before ever looking at the
422
+ // expected pid, so a host whose libc would not load failed OPEN while the
423
+ // startup banner claimed it refused everything.
424
+ if (expected.pid !== undefined) {
425
+ return {
426
+ ok: false,
427
+ peerUid: -1,
428
+ expectedUid: expected.uid,
429
+ detail:
430
+ `this channel belongs to pid ${expected.pid} and this host reports no peer credentials at all, ` +
431
+ `so the caller cannot be identified — every session here shares one uid, and the socket mode ` +
432
+ `cannot tell them apart`,
433
+ };
434
+ }
416
435
  return {
417
436
  ok: true,
418
437
  basis: "socket-ownership",
419
- why: "this host exposes no peer-credential call, so the 0600 socket under the daemon-owned 0711 parent is all that keeps other local accounts out",
438
+ why: "this host exposes no peer-credential call and no pid was expected, so the 0600 socket under the daemon-owned 0711 parent is all that keeps other local accounts out",
420
439
  };
421
440
  }
422
441
  if (peer.uid !== expected.uid) {
@@ -457,7 +476,7 @@ export function peerVerdict(
457
476
  export function transportBanner(dir: string, peerReader: PeerReader | undefined): string {
458
477
  const peers =
459
478
  peerReader === undefined
460
- ? `no peer-credential call on ${process.platform} — channels REFUSE every connection`
479
+ ? `no peer-credential call on ${process.platform} — every bound channel REFUSES every connection`
461
480
  : process.platform === "darwin"
462
481
  ? "caller pid+uid asserted with getpeereid and LOCAL_PEERPID; each channel bound to the session the daemon launched"
463
482
  : "caller pid+uid asserted with SO_PEERCRED; each channel bound to the session the daemon launched";