sessionmail 0.2.0__tar.gz → 0.2.1__tar.gz

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.
Files changed (86) hide show
  1. {sessionmail-0.2.0 → sessionmail-0.2.1}/.claude-plugin/plugin.json +1 -1
  2. {sessionmail-0.2.0 → sessionmail-0.2.1}/PKG-INFO +1 -11
  3. {sessionmail-0.2.0 → sessionmail-0.2.1}/README.md +0 -10
  4. {sessionmail-0.2.0 → sessionmail-0.2.1}/scripts/seat-agent/README.md +12 -4
  5. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_birth.py +28 -5
  6. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_drop.py +14 -7
  7. sessionmail-0.2.1/src/sessionmail/_launchd.py +426 -0
  8. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_magicdns.py +52 -4
  9. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_mhs.py +20 -2
  10. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_render.py +6 -3
  11. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_seat.py +63 -1
  12. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_sig.py +13 -5
  13. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_voice.py +1 -1
  14. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/message.py +40 -15
  15. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/session-mail-run +11 -8
  16. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/pager.py +12 -2
  17. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/seat/seat-agent.plist.template +4 -3
  18. {sessionmail-0.2.0 → sessionmail-0.2.1}/.gitignore +0 -0
  19. {sessionmail-0.2.0 → sessionmail-0.2.1}/LICENSE +0 -0
  20. {sessionmail-0.2.0 → sessionmail-0.2.1}/hooks/mail-guard/README.md +0 -0
  21. {sessionmail-0.2.0 → sessionmail-0.2.1}/plugins/pager-monitor/README.md +0 -0
  22. {sessionmail-0.2.0 → sessionmail-0.2.1}/pyproject.toml +0 -0
  23. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/__init__.py +0 -0
  24. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_archive.py +0 -0
  25. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_arm.py +0 -0
  26. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_boxes.py +0 -0
  27. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_detect.py +0 -0
  28. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_detect_imap.py +0 -0
  29. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_generator.py +0 -0
  30. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_headers.py +0 -0
  31. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_help.py +0 -0
  32. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_index.py +0 -0
  33. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_init.py +0 -0
  34. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_injectlog.py +0 -0
  35. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_layer.py +0 -0
  36. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_maildir.py +0 -0
  37. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_mcp.py +0 -0
  38. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_metrics.py +0 -0
  39. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_migrate.py +0 -0
  40. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_notmuch.py +0 -0
  41. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_operator.py +0 -0
  42. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_parsers.py +0 -0
  43. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_pull_imap.py +0 -0
  44. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_reach.py +0 -0
  45. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_record.py +0 -0
  46. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_root.py +0 -0
  47. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_say.py +0 -0
  48. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_send.py +0 -0
  49. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_sender.py +0 -0
  50. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_settings.py +0 -0
  51. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_setup.py +0 -0
  52. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_shorten.py +0 -0
  53. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_stub.py +0 -0
  54. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_subscribe.py +0 -0
  55. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_subscription.py +0 -0
  56. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_tokens.py +0 -0
  57. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_views.py +0 -0
  58. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/_why.py +0 -0
  59. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/arm.txt +0 -0
  60. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/hook_lines.toml +0 -0
  61. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/humans +0 -0
  62. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mail.schema.json +0 -0
  63. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/memory.py +0 -0
  64. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/mail-box.lib.sh +0 -0
  65. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/mail-ext.lib.sh +0 -0
  66. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/mhs-thread +0 -0
  67. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/session-mail-cat +0 -0
  68. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/session-mail-ls +0 -0
  69. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/session-mail-tree +0 -0
  70. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/session-mail-verify +0 -0
  71. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/mhs/session-mail-wake +0 -0
  72. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/notmuch/notmuch-config.sessionkit.template +0 -0
  73. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/notmuch/notmuch-config.store.template +0 -0
  74. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/notmuch/post-new +0 -0
  75. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/notmuch/store-index +0 -0
  76. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/postfix/delivery-check +0 -0
  77. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/postfix/magicdns-suffix +0 -0
  78. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/postfix/nm-deliver +0 -0
  79. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/postfix/vmailbox-gen +0 -0
  80. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/rules.py +0 -0
  81. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/seat/seat-agent +0 -0
  82. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/seat_init.py +0 -0
  83. {sessionmail-0.2.0 → sessionmail-0.2.1}/src/sessionmail/settings.template.json +0 -0
  84. {sessionmail-0.2.0 → sessionmail-0.2.1}/tests/fixtures/sig_v2/README.md +0 -0
  85. {sessionmail-0.2.0 → sessionmail-0.2.1}/tests/fixtures/sm827_hint_root/README.md +0 -0
  86. {sessionmail-0.2.0 → sessionmail-0.2.1}/tests/fixtures/sm827_honest/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sessionmail",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Mail for coding agents: message moves messages, pager alerts, memory is the filesystem view - three commands over one maildir.",
5
5
  "author": {
6
6
  "name": "Mike Bailey",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sessionmail
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: sessionmail - mail for coding agents: message, pager and memory over one maildir.
5
5
  Project-URL: Homepage, https://github.com/sessionkit/sessionmail
6
6
  Project-URL: Repository, https://github.com/sessionkit/sessionmail
@@ -104,16 +104,6 @@ message setup # again: it links the notmuch hook
104
104
  - **Nothing runs `notmuch new` for you.** `message mark` and `memory`
105
105
  read an empty index until something does: launchd, cron, or by hand.
106
106
 
107
- **Status.** `sessionmail` 0.0.1 on PyPI is a name reservation, not this
108
- code - a 1.3 KB placeholder with no dependencies.
109
- Until 0.2.0 ships, install from source instead of the `uv tool install
110
- sessionmail` line above:
111
-
112
- ```sh
113
- uv tool install git+https://github.com/sessionkit/sessionmail # from the repo
114
- make install # from a clone
115
- ```
116
-
117
107
  `message`, `pager` and `memory` all run; the specs, in the git repository
118
108
  (the release archive leaves them out), say what each verb is meant to do
119
109
  and `make progress` counts how much of it is built.
@@ -85,16 +85,6 @@ message setup # again: it links the notmuch hook
85
85
  - **Nothing runs `notmuch new` for you.** `message mark` and `memory`
86
86
  read an empty index until something does: launchd, cron, or by hand.
87
87
 
88
- **Status.** `sessionmail` 0.0.1 on PyPI is a name reservation, not this
89
- code - a 1.3 KB placeholder with no dependencies.
90
- Until 0.2.0 ships, install from source instead of the `uv tool install
91
- sessionmail` line above:
92
-
93
- ```sh
94
- uv tool install git+https://github.com/sessionkit/sessionmail # from the repo
95
- make install # from a clone
96
- ```
97
-
98
88
  `message`, `pager` and `memory` all run; the specs, in the git repository
99
89
  (the release archive leaves them out), say what each verb is meant to do
100
90
  and `make progress` counts how much of it is built.
@@ -9,6 +9,8 @@ through `message`, which keeps the package at three commands:
9
9
 
10
10
  message seat init --registry DIR ADDRESS mint the seat's key (was scripts/seat-init)
11
11
  message seat agent ADDRESS [SOCKET] run its agent (was scripts/seat-agent/seat-agent)
12
+ message seat agent --install ADDRESS [SOCKET] render and load the LaunchAgent
13
+ message seat agent --uninstall ADDRESS unload it and remove its plist
12
14
  message seat agent --template print the LaunchAgent template
13
15
 
14
16
  The `seat` noun is hidden from `message --help` and from the MCP tools; `message
@@ -48,8 +50,14 @@ seat --help` answers when typed.
48
50
 
49
51
  ## The template
50
52
 
53
+ `message seat agent --install ADDRESS [SOCKET]` renders it, writes it into
54
+ `~/Library/LaunchAgents`, loads it with `launchctl bootstrap`, and checks that the agent
55
+ answers. `--uninstall ADDRESS` boots it out and removes the plist. Their contract
56
+ (output, exits, the `Capability: install` marker) is in
57
+ [`docs/message-seat.md`](../../docs/message-seat.md).
58
+
51
59
  `message seat agent --template` prints it, and nothing else: it never renders it, loads
52
- it or runs launchctl. Render it by replacing each `@PLACEHOLDER@`, then lint it:
60
+ it or runs launchctl. To render it by hand, replace each `@PLACEHOLDER@`, then lint it:
53
61
 
54
62
  message seat agent --template |
55
63
  sed -e 's|@ADDRESS@|bot.alice|g' -e "s|@MESSAGE@|$(command -v message)|g" \
@@ -68,9 +76,9 @@ ProgramArguments renders to `message seat agent ADDRESS SOCKET`. The placeholder
68
76
  On a host that runs `message` from an editable checkout, merging there changes the
69
77
  seat's agent at its next start.
70
78
 
71
- **Installing it is not done here.** Writing the plist into `~/Library/LaunchAgents` and
72
- running `launchctl bootstrap` is the host operator's act, by hand. No
73
- test and no make target in this repo does either.
79
+ **No test and no make target loads a job on the real launchd.** The suite runs
80
+ `--install` against a fake `launchctl` (`SESSIONMAIL_LAUNCHCTL`). A live install is the
81
+ host operator's act: `message seat agent --install`, or the hand render above.
74
82
 
75
83
  ## The adapter's env (an example; the adapter owns it)
76
84
 
@@ -68,6 +68,19 @@ SUBSCRIBE_VAR = "SESSIONMAIL_SUBSCRIBE"
68
68
  #: one: the detached generator's line.
69
69
  BORN_FILE = "born"
70
70
 
71
+ #: What a generator line says on a mail-drop host, suffix or none: the drop
72
+ #: builds no maps, so no generator runs there (`_seat._run_generator`'s own
73
+ #: words for it; `tests/test_birth.py` holds the two equal).
74
+ DROP_REGEN = "generator not run: the carrier is the mail drop; it reads the maps per send"
75
+
76
+
77
+ def _on_drop(env: Mapping[str, str] | None) -> bool:
78
+ """Whether the carrier is the mail drop. Decides what a generator line
79
+ SAYS, never what runs."""
80
+ from . import _settings
81
+ return _settings.carrier(env).kind == "drop"
82
+
83
+
71
84
  #: Who births when the caller does not say: the subscription journal's `argv`.
72
85
  VIA_LISTEN = "pager listen"
73
86
 
@@ -350,13 +363,19 @@ def ensure_session_box(session: str, seat: "Seat | None", *,
350
363
  f"{stale}, left by an earlier run of this session; its row "
351
364
  f"goes at the next regen")
352
365
  if has_session and seat is not None:
353
- live, why = _seat.doorbell(seat.address, session, env)
366
+ if detach and _settings.carrier(env).kind == "drop":
367
+ # The drop's doorbell runs the generator (`_drop.prepare`), and a
368
+ # detaching call never waits on it; the listen asks instead.
369
+ live, why = None, "not measured at session start; the first pager listen measures it"
370
+ else:
371
+ live, why = _seat.doorbell(seat.address, session, env)
354
372
  regen_said = ""
355
373
  if (birth.born or live is False) and detach:
356
374
  _detach(birth, session, env, root, record_source)
357
375
  regen_said = birth.regen_line
358
376
  elif (birth.born or live is False) and birth.suffix_unknown:
359
- regen_said = "generator NOT run (MagicDNS suffix unknown)"
377
+ regen_said = (DROP_REGEN if _on_drop(env)
378
+ else "generator NOT run (MagicDNS suffix unknown)")
360
379
  elif birth.born or live is False:
361
380
  regen_said = _seat.regen_maps(root, env=env)
362
381
  birth.regen, birth.regen_line = "ran", regen_said
@@ -384,7 +403,8 @@ def ensure_session_box(session: str, seat: "Seat | None", *,
384
403
  # included: the whole generator, detached, routes the boxes too.
385
404
  _detach(birth, session, env, root, record_source)
386
405
  elif owed and birth.suffix_unknown:
387
- birth.sub_lines.append(" map : NOT rebuilt - MagicDNS suffix unknown; the "
406
+ birth.sub_lines.append(f" map : {DROP_REGEN}" if _on_drop(env) else
407
+ " map : NOT rebuilt - MagicDNS suffix unknown; the "
388
408
  "subscriptions stand, and the next rebuild with a suffix "
389
409
  "routes them")
390
410
  elif owed:
@@ -429,8 +449,11 @@ def _main(argv: list[str] | None = None) -> int:
429
449
  # The hook did not ask for the suffix (it costs the hook's
430
450
  # budget), so the child asks, and never runs the generator without
431
451
  # one: the record's line 2 says so instead.
452
+ # On a mail-drop host no generator runs either way, so the line
453
+ # names the carrier, not the suffix.
432
454
  suffix, why = _magicdns.lookup()
433
455
  line = (_seat.regen_maps(Path(args.regen)) if suffix is not None
456
+ else DROP_REGEN if _on_drop(None)
434
457
  else f"generator NOT run (MagicDNS suffix unknown: {why})")
435
458
  except Exception as exc: # noqa: BLE001 - regen_maps never raises; a child that dies says why
436
459
  line = f"generator failed in the background: {exc}"
@@ -443,8 +466,8 @@ def _main(argv: list[str] | None = None) -> int:
443
466
  return 0
444
467
 
445
468
 
446
- __all__ = ["BORN_FILE", "SUBSCRIBE_VAR", "Birth", "BornRecord", "birth_subscriptions", "born_record",
447
- "detach_regen", "ensure_session_box", "read_born"]
469
+ __all__ = ["BORN_FILE", "DROP_REGEN", "SUBSCRIBE_VAR", "Birth", "BornRecord", "birth_subscriptions",
470
+ "born_record", "detach_regen", "ensure_session_box", "read_born"]
448
471
 
449
472
 
450
473
  if __name__ == "__main__":
@@ -132,6 +132,11 @@ class Maps:
132
132
  #: The domain a bare From is qualified with (`origin`); set by the
133
133
  #: sender's drop pre-flight, empty when nothing asked.
134
134
  origin: str = ""
135
+ #: Why the generator refused the alias print (`generator exit N: its
136
+ #: last stderr line`), set by `prepare`; empty when it printed. The
137
+ #: alias map is then empty, and a reader that must tell "no row" from
138
+ #: "no map" (the doorbell) reads this.
139
+ alias_refused: str = ""
135
140
 
136
141
 
137
142
  def selected(env: Mapping[str, str]) -> bool:
@@ -183,16 +188,18 @@ def origin(env: Mapping[str, str], host: str, notes: list[str] | None = None) ->
183
188
  - Else the FQDN from the ONE suffix definition (`_magicdns`), the form
184
189
  postfix writes when `myorigin = $myhostname`, and a form
185
190
  `_voice.host_forms` accepts.
186
- - Else the short host, said in NOTES: the real generator refuses with
187
- no suffix before the drop gets here, so only a generator that asks
188
- for none (a test's fake) reaches it."""
191
+ - Else the short host. A host with no tailnet gets here on every send
192
+ (the generator's no-fqdn rows), and there the short host is right, so
193
+ nothing is said (`_magicdns.drop_without_tailnet`). Any other host
194
+ (a malformed operator suffix, or a tailscale that is there but does
195
+ not answer) is said in NOTES."""
189
196
  given = (env.get(ORIGIN_VAR) or "").strip() or _settings.file_text("postfix.origin", env)
190
197
  if given:
191
198
  return given
192
199
  found, _why = _magicdns.lookup(env)
193
200
  if found:
194
201
  return f"{host}.{found}"
195
- if notes is not None:
202
+ if notes is not None and not _magicdns.drop_without_tailnet(env):
196
203
  notes.append(f"⚠ {_magicdns.UNKNOWN} - a bare From is qualified with the short "
197
204
  f"host only (@{host})")
198
205
  return host
@@ -308,9 +315,9 @@ def prepare(env: Mapping[str, str]) -> Maps:
308
315
  if done.returncode == 0:
309
316
  maps.alias = _rows(done.stdout)
310
317
  else:
311
- maps.notes.append(f"⚠ no alias map (generator exit {done.returncode}: "
312
- f"{_last(done.stderr)}) - seat mail lands in the seat's own box "
313
- "only; no session box gets its doorbell copy")
318
+ maps.alias_refused = f"generator exit {done.returncode}: {_last(done.stderr)}"
319
+ maps.notes.append(f"⚠ no alias map ({maps.alias_refused}) - seat mail lands in the "
320
+ "seat's own box only; no session box gets its doorbell copy")
314
321
  return maps
315
322
 
316
323
 
@@ -0,0 +1,426 @@
1
+ """The seat agent's LaunchAgent: render it, load it, unload it.
2
+
3
+ `message seat agent --install ADDRESS [SOCKET]` and `--uninstall ADDRESS`.
4
+ A seat with no Claude session (a bot) signs through its own ssh-agent, and
5
+ that agent has to come back after a reboot: launchd keeps it alive. This
6
+ module renders the packaged template (`seat/seat-agent.plist.template`),
7
+ writes it into `~/Library/LaunchAgents`, loads it with `launchctl
8
+ bootstrap`, and checks the effect: the agent answers on its socket and
9
+ holds seat.pub's key. `--uninstall` boots the job out and removes the plist.
10
+ It never touches the key, the registry or the cert.
11
+
12
+ Streams and exits follow `seat init`'s convention:
13
+
14
+ - stdout: the success report only (`--install`'s KEY=VALUE lines).
15
+ - stderr: narration, refusals (`REFUSED - ... Nothing was written.`) and
16
+ failures (`FAILED - ...`, naming what was put back).
17
+ - 0 done · 1 launchctl failed, a plist write or removal failed, or the
18
+ agent never answered, rolled back · 2 usage or refused, nothing written.
19
+
20
+ `launchctl` is `$SESSIONMAIL_LAUNCHCTL` when set and not empty, else
21
+ `/bin/launchctl`: the same seam as `SESSIONMAIL_SENDMAIL`. The test suite
22
+ points it at a fake for every test, so no test reaches the real launchd.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import plistlib
29
+ import re
30
+ import shlex
31
+ import shutil
32
+ import subprocess
33
+ import sys
34
+ import tempfile
35
+ import time
36
+ from dataclasses import dataclass
37
+ from typing import IO, Mapping
38
+ from xml.sax.saxutils import escape
39
+
40
+ PROG = "message seat agent"
41
+
42
+ EX_OK = 0
43
+ EX_FAILED = 1
44
+ EX_REFUSED = 2
45
+
46
+ #: The seam: a launchctl of the caller's choosing (a test's fake).
47
+ LAUNCHCTL_VAR = "SESSIONMAIL_LAUNCHCTL"
48
+ DEFAULT_LAUNCHCTL = "/bin/launchctl"
49
+
50
+ #: The kit's launchd family for mail, as the template's Label has it.
51
+ LABEL_PREFIX = "dev.sessionkit.mail.seat-agent."
52
+ #: The wrapper's own rules (seat/seat-agent): the address, and the socket
53
+ #: cap. macOS's sun_path is 104 bytes with its NUL.
54
+ ADDRESS_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
55
+ SOCK_MAX = 103
56
+ PLACEHOLDER = re.compile(r"@([A-Z_]+)@")
57
+
58
+ #: How long the agent has to answer after a load, and the job to go after
59
+ #: a bootout. Module values, so a test can shorten them in-process.
60
+ ANSWER_SECONDS = 10.0
61
+ GONE_SECONDS = 5.0
62
+ POLL_SECONDS = 0.1
63
+ #: How many lines of the job's log a failure quotes.
64
+ LOG_TAIL = 8
65
+
66
+ #: The packaged template, beside this module's real path.
67
+ TEMPLATE = os.path.join(os.path.dirname(os.path.realpath(__file__)), "seat",
68
+ "seat-agent.plist.template")
69
+
70
+
71
+ class Refused(Exception):
72
+ """Exit 2: usage or refused, nothing written."""
73
+
74
+
75
+ class Failed(Exception):
76
+ """Exit 1: a tool failed; the message names what was put back."""
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Job:
81
+ address: str
82
+ label: str
83
+ plist: str # ~/Library/LaunchAgents/<label>.plist
84
+ socket: str
85
+ pub: str
86
+ key: str
87
+ log: str # <log_dir>/seat-agent.<address>.log
88
+ log_dir: str
89
+ sessionmail_home: str
90
+ message: str # `command -v message`, not resolved
91
+ domain: str # gui/<uid>
92
+
93
+ @property
94
+ def target(self) -> str:
95
+ return f"{self.domain}/{self.label}"
96
+
97
+
98
+ def label_for(address: str) -> str:
99
+ return LABEL_PREFIX + address
100
+
101
+
102
+ def launchctl_path(env: Mapping[str, str]) -> str:
103
+ return env.get(LAUNCHCTL_VAR) or DEFAULT_LAUNCHCTL
104
+
105
+
106
+ def launchctl(env: Mapping[str, str], *args: str) -> subprocess.CompletedProcess:
107
+ """Run launchctl ARGS (the seam's, else /bin/launchctl), output captured.
108
+ A launchctl that cannot be started is a Failed, before anything moves."""
109
+ path = launchctl_path(env)
110
+ try:
111
+ return subprocess.run([path, *args], capture_output=True, text=True,
112
+ stdin=subprocess.DEVNULL, env=dict(env), check=False)
113
+ except OSError as exc:
114
+ raise Failed(f"launchctl could not run ({path}: {exc.strerror or exc})") from exc
115
+
116
+
117
+ def _words(done: subprocess.CompletedProcess) -> str:
118
+ said = " ".join((done.stderr or done.stdout or "").split())
119
+ return f"rc {done.returncode}" + (f": {said}" if said else "")
120
+
121
+
122
+ def _sessionmail_home(env: Mapping[str, str], home: str) -> str:
123
+ """As the wrapper reads it: $SESSIONMAIL_HOME, a leading ~ expanded, else
124
+ $HOME/.sessionmail. Absolute, as the template wants it."""
125
+ value = env.get("SESSIONMAIL_HOME") or os.path.join(home, ".sessionmail")
126
+ if value == "~" or value.startswith("~/"):
127
+ value = home + value[1:]
128
+ return os.path.abspath(value)
129
+
130
+
131
+ def _check_platform() -> None:
132
+ if sys.platform != "darwin":
133
+ raise Refused(f"launchd only: this is {sys.platform}, not macOS; run "
134
+ f"`{PROG} ADDRESS [SOCKET]` under your own service manager")
135
+
136
+
137
+ def _check_address(address: str) -> None:
138
+ if "+" in address:
139
+ raise Refused(f"'{address}' has a '+': a seat is named with a dot (bot.alice), "
140
+ "as `message seat init` mints it")
141
+ if not ADDRESS_RE.match(address):
142
+ raise Refused(f"'{address}' is not a seat address")
143
+
144
+
145
+ def _home(env: Mapping[str, str]) -> str:
146
+ home = env.get("HOME", "")
147
+ if not os.path.isabs(home):
148
+ raise Refused("HOME is unset or not absolute: the LaunchAgent lives under "
149
+ "$HOME/Library/LaunchAgents")
150
+ return home
151
+
152
+
153
+ def plan(address: str, socket: str | None, env: Mapping[str, str]) -> Job:
154
+ """The job for ADDRESS, every install check made; nothing is written."""
155
+ _check_platform()
156
+ _check_address(address)
157
+ home = _home(env)
158
+ sm_home = _sessionmail_home(env, home)
159
+ key_home = os.path.join(sm_home, "seat-keys", address)
160
+ key = os.path.join(key_home, "id_ed25519")
161
+ pub = os.path.join(key_home, "seat.pub")
162
+ if not os.path.isdir(key_home):
163
+ raise Refused(f"no key home {key_home}: mint the seat first "
164
+ f"(message seat init --registry DIR {address})")
165
+ for path in (key, pub):
166
+ if not os.path.isfile(path):
167
+ raise Refused(f"no {os.path.basename(path)} at {path}: mint the seat first "
168
+ f"(message seat init --registry DIR {address})")
169
+ if socket is None:
170
+ socket = os.path.join(key_home, "agent.sock")
171
+ elif not os.path.isabs(socket):
172
+ raise Refused(f"SOCKET {socket!r} is not an absolute path: launchd starts the "
173
+ "job in /, so name it in full")
174
+ size = len(socket.encode())
175
+ if size > SOCK_MAX:
176
+ raise Refused(f"socket path is {size} bytes; macOS binds {SOCK_MAX} at most "
177
+ "(104 with its NUL). Name a shorter SOCKET")
178
+ if not os.path.isdir(os.path.dirname(socket)):
179
+ raise Refused(f"no directory {os.path.dirname(socket)} for the socket")
180
+ message = shutil.which("message", path=env["PATH"]) if env.get("PATH") else None
181
+ if message is None:
182
+ raise Refused("`message` is not on PATH: launchd runs `message seat agent`, "
183
+ "by the path `command -v message` prints")
184
+ label = label_for(address)
185
+ log_dir = os.path.join(home, "Library", "Logs")
186
+ return Job(address=address, label=label,
187
+ plist=os.path.join(home, "Library", "LaunchAgents", f"{label}.plist"),
188
+ socket=socket, pub=pub, key=key,
189
+ log=os.path.join(log_dir, f"seat-agent.{address}.log"), log_dir=log_dir,
190
+ sessionmail_home=sm_home, message=os.path.abspath(message),
191
+ domain=f"gui/{os.getuid()}")
192
+
193
+
194
+ def render(template: bytes, job: Job) -> bytes:
195
+ """The template with its five placeholders filled, each value XML-escaped.
196
+ One pass, so a value is never itself read for a placeholder. An unknown
197
+ placeholder, a render plistlib cannot read, or a Label that is not the
198
+ job's is refused."""
199
+ values = {"ADDRESS": job.address, "MESSAGE": job.message,
200
+ "SESSIONMAIL_HOME": job.sessionmail_home, "SOCKET": job.socket,
201
+ "LOG_DIR": job.log_dir}
202
+ text = template.decode("utf-8")
203
+ unknown = sorted(set(PLACEHOLDER.findall(text)) - set(values))
204
+ if unknown:
205
+ raise Refused("the packaged template holds a placeholder this sessionmail does not "
206
+ f"fill ({', '.join('@' + u + '@' for u in unknown)}): a broken install")
207
+ out = PLACEHOLDER.sub(lambda m: escape(values[m.group(1)]), text).encode("utf-8")
208
+ try:
209
+ parsed = plistlib.loads(out)
210
+ except Exception as exc: # plistlib raises several kinds on a bad document
211
+ raise Refused(f"the rendered LaunchAgent is not a valid plist ({exc})") from exc
212
+ if parsed.get("Label") != job.label:
213
+ raise Refused(f"the rendered Label is {parsed.get('Label')!r}, not {job.label!r}")
214
+ return out
215
+
216
+
217
+ def loaded(job_target: str, env: Mapping[str, str]) -> bool:
218
+ """`launchctl print TARGET`: rc 0 means the job is loaded."""
219
+ return launchctl(env, "print", job_target).returncode == 0
220
+
221
+
222
+ def _read(path: str) -> bytes | None:
223
+ try:
224
+ with open(path, "rb") as f:
225
+ return f.read()
226
+ except FileNotFoundError:
227
+ return None
228
+
229
+
230
+ def _write(path: str, data: bytes) -> None:
231
+ """Write DATA at PATH, 0644, through a temp file and os.replace."""
232
+ os.makedirs(os.path.dirname(path), mode=0o755, exist_ok=True)
233
+ fd, tmp = tempfile.mkstemp(prefix=".seat-agent.", dir=os.path.dirname(path))
234
+ try:
235
+ with os.fdopen(fd, "wb") as f:
236
+ f.write(data)
237
+ os.chmod(tmp, 0o644)
238
+ os.replace(tmp, path)
239
+ except BaseException:
240
+ if os.path.exists(tmp):
241
+ os.unlink(tmp)
242
+ raise
243
+
244
+
245
+ def answers(job: Job, env: Mapping[str, str]) -> bool:
246
+ """The agent on the socket lists seat.pub's key. A clean env: only this
247
+ socket, never the caller's SSH_AUTH_SOCK."""
248
+ ssh_add = shutil.which("ssh-add", path=env.get("PATH", "")) or "/usr/bin/ssh-add"
249
+ try:
250
+ with open(job.pub) as f:
251
+ blob = f.read().split()[1]
252
+ except (OSError, IndexError):
253
+ return False
254
+ clean = {"PATH": "/usr/bin:/bin", "HOME": env.get("HOME", "/"), "SSH_AUTH_SOCK": job.socket}
255
+ try:
256
+ done = subprocess.run([ssh_add, "-L"], capture_output=True, text=True, env=clean,
257
+ stdin=subprocess.DEVNULL, timeout=5, check=False)
258
+ except (OSError, subprocess.TimeoutExpired):
259
+ return False
260
+ return done.returncode == 0 and blob in done.stdout
261
+
262
+
263
+ def _wait(predicate, seconds: float) -> bool:
264
+ deadline = time.monotonic() + seconds
265
+ while True:
266
+ if predicate():
267
+ return True
268
+ if time.monotonic() >= deadline:
269
+ return False
270
+ time.sleep(POLL_SECONDS)
271
+
272
+
273
+ def _log_tail(job: Job) -> str:
274
+ try:
275
+ with open(job.log, errors="replace") as f:
276
+ lines = f.read().splitlines()[-LOG_TAIL:]
277
+ except OSError:
278
+ return f"(no log at {job.log})"
279
+ return f"the last lines of {job.log}:\n" + "\n".join(" " + line for line in lines)
280
+
281
+
282
+ def report(job: Job) -> str:
283
+ """The stdout contract: KEY=VALUE, shell-quoted, for `eval`."""
284
+ pairs = (("SEAT_LABEL", job.label), ("SEAT_PLIST", job.plist), ("SEAT_SOCKET", job.socket),
285
+ ("SEAT_PUB", job.pub), ("SEAT_LOG", job.log))
286
+ return "".join(f"{k}={shlex.quote(v)}\n" for k, v in pairs)
287
+
288
+
289
+ def install(address: str, socket: str | None = None, *, env: Mapping[str, str] | None = None,
290
+ out: IO[str] | None = None, err: IO[str] | None = None) -> int:
291
+ env = os.environ if env is None else env
292
+ out = sys.stdout if out is None else out
293
+ err = sys.stderr if err is None else err
294
+ try:
295
+ return _install(address, socket, env, out, err)
296
+ except Refused as exc:
297
+ err.write(f"{PROG}: REFUSED - {exc}. Nothing was written.\n")
298
+ return EX_REFUSED
299
+ except Failed as exc:
300
+ err.write(f"{PROG}: FAILED - {exc}\n")
301
+ return EX_FAILED
302
+
303
+
304
+ def _install(address: str, socket: str | None, env: Mapping[str, str],
305
+ out: IO[str], err: IO[str]) -> int:
306
+ job = plan(address, socket, env)
307
+ with open(TEMPLATE, "rb") as f:
308
+ rendered = render(f.read(), job)
309
+ existing = _read(job.plist)
310
+ is_loaded = loaded(job.target, env)
311
+ if is_loaded and existing == rendered:
312
+ if not _wait(lambda: answers(job, env), ANSWER_SECONDS):
313
+ raise Failed(f"{job.label} is loaded but its agent does not answer on {job.socket} "
314
+ f"with seat.pub's key; the job was left as found (this run did not "
315
+ f"load it). {_log_tail(job)}")
316
+ err.write(f"{PROG}: {job.label} already installed; the agent answers on {job.socket}\n")
317
+ out.write(report(job))
318
+ return EX_OK
319
+ if is_loaded:
320
+ why = "with a different plist" if existing is not None else f"with no plist at {job.plist}"
321
+ raise Refused(f"a job already holds {job.label} ({why}): run "
322
+ f"`{PROG} --uninstall {address}` first")
323
+ if existing is not None and existing != rendered:
324
+ raise Refused(f"a different plist is at {job.plist}: run "
325
+ f"`{PROG} --uninstall {address}` first; a job's plist is never overwritten")
326
+
327
+ wrote = existing is None
328
+ # An OSError here is a FAILED line, never a traceback. _write leaves no temp
329
+ # file and no plist behind, and nothing has been loaded yet. A plist that was
330
+ # there before this run is never touched, so the line says it is kept.
331
+ path = job.log_dir
332
+ try:
333
+ os.makedirs(job.log_dir, exist_ok=True)
334
+ path = job.plist
335
+ if wrote:
336
+ _write(job.plist, rendered)
337
+ except OSError as exc:
338
+ kept = ("no plist was left" if wrote
339
+ else f"the plist at {job.plist} was there before this run and is kept")
340
+ raise Failed(f"could not write {path} ({exc.strerror or exc}); nothing was "
341
+ f"loaded and {kept}") from exc
342
+ done = launchctl(env, "bootstrap", job.domain, job.plist)
343
+ if done.returncode != 0:
344
+ put_back = _remove_ours(job, wrote)
345
+ raise Failed(f"launchctl bootstrap {job.domain} {job.plist} failed ({_words(done)}); "
346
+ f"{put_back}")
347
+ if not _wait(lambda: answers(job, env), ANSWER_SECONDS):
348
+ off = launchctl(env, "bootout", job.target)
349
+ booted = ("booted it out" if off.returncode == 0
350
+ else f"its bootout failed too ({_words(off)})")
351
+ put_back = _remove_ours(job, wrote)
352
+ raise Failed(f"loaded {job.label}, but its agent did not answer on {job.socket} with "
353
+ f"seat.pub's key within {ANSWER_SECONDS:g} s; {booted}; {put_back}. "
354
+ f"{_log_tail(job)}")
355
+ err.write(f"{PROG}: installed and loaded {job.label}; the agent answers on {job.socket}\n")
356
+ out.write(report(job))
357
+ return EX_OK
358
+
359
+
360
+ def _remove_ours(job: Job, wrote: bool) -> str:
361
+ if not wrote:
362
+ return f"the plist at {job.plist} was there before this run and is kept"
363
+ try:
364
+ os.unlink(job.plist)
365
+ except FileNotFoundError:
366
+ pass
367
+ except OSError as exc:
368
+ return f"the plist this run wrote could NOT be removed ({job.plist}: {exc.strerror})"
369
+ return f"the plist this run wrote is removed ({job.plist})"
370
+
371
+
372
+ def uninstall(address: str, *, env: Mapping[str, str] | None = None,
373
+ err: IO[str] | None = None) -> int:
374
+ env = os.environ if env is None else env
375
+ err = sys.stderr if err is None else err
376
+ try:
377
+ return _uninstall(address, env, err)
378
+ except Refused as exc:
379
+ err.write(f"{PROG}: REFUSED - {exc}. Nothing was changed.\n")
380
+ return EX_REFUSED
381
+ except Failed as exc:
382
+ err.write(f"{PROG}: FAILED - {exc}\n")
383
+ return EX_FAILED
384
+
385
+
386
+ def _uninstall(address: str, env: Mapping[str, str], err: IO[str]) -> int:
387
+ _check_platform()
388
+ _check_address(address)
389
+ home = _home(env)
390
+ label = label_for(address)
391
+ plist = os.path.join(home, "Library", "LaunchAgents", f"{label}.plist")
392
+ target = f"gui/{os.getuid()}/{label}"
393
+ existing = _read(plist)
394
+ if existing is not None:
395
+ try:
396
+ theirs = plistlib.loads(existing).get("Label")
397
+ except Exception:
398
+ theirs = None
399
+ if theirs != label:
400
+ raise Refused(f"the plist at {plist} has Label {theirs!r}, not {label!r}: "
401
+ "not this tool's to remove")
402
+ said = []
403
+ if loaded(target, env):
404
+ off = launchctl(env, "bootout", target)
405
+ if off.returncode != 0:
406
+ raise Failed(f"launchctl bootout {target} failed ({_words(off)}); "
407
+ f"the plist is kept" + (f" at {plist}" if existing is not None else ""))
408
+ if not _wait(lambda: not loaded(target, env), GONE_SECONDS):
409
+ raise Failed(f"{label} is still loaded {GONE_SECONDS:g} s after its bootout; "
410
+ "the plist is kept" + (f" at {plist}" if existing is not None else ""))
411
+ said.append(f"booted out {label}")
412
+ if existing is not None:
413
+ try:
414
+ os.unlink(plist)
415
+ except FileNotFoundError:
416
+ pass
417
+ except OSError as exc:
418
+ done = f"{'; '.join(said)}; but " if said else ""
419
+ raise Failed(f"{done}could not remove {plist} ({exc.strerror or exc}); "
420
+ "the plist is kept") from exc
421
+ said.append(f"removed {plist}")
422
+ if not said:
423
+ err.write(f"{PROG}: nothing installed for {address}\n")
424
+ else:
425
+ err.write(f"{PROG}: {'; '.join(said)}\n")
426
+ return EX_OK