alissa-tools-github-orcloop 0.3.0__tar.gz → 0.3.2__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 (23) hide show
  1. {alissa_tools_github_orcloop-0.3.0/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.2}/PKG-INFO +1 -1
  2. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/__main__.py +112 -12
  3. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/alissa_client.py +58 -0
  4. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/config.py +281 -35
  5. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/loop.py +24 -3
  6. alissa_tools_github_orcloop-0.3.2/src/main/alissa/tools/github/orcloop/version +1 -0
  7. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
  8. alissa_tools_github_orcloop-0.3.0/src/main/alissa/tools/github/orcloop/version +0 -1
  9. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/MANIFEST.in +0 -0
  10. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/README.md +0 -0
  11. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/requirements.txt +0 -0
  12. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/setup.cfg +0 -0
  13. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/setup.py +0 -0
  14. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
  15. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
  16. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
  17. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
  18. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/state.py +0 -0
  19. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/version.py +0 -0
  20. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
  21. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
  22. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
  23. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa_tools_github_orcloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -2,7 +2,8 @@
2
2
 
3
3
  O1 wired the config surface and the argument parser; O3 wires the decision
4
4
  loop behind it; O6 adds the release-feed selector (`--release-feed bow` and its
5
- `--bow-prefix` / `--bow-owner` / `--scan-fallback-interval` knobs).
5
+ `--bow-prefix` / `--bow-owner` / `--scan-fallback-interval` knobs); O8 makes the
6
+ feed's authority resolve itself (see `resolve_feed_authority`).
6
7
 
7
8
  The CLI resolves and validates the full configuration
8
9
  (four-layer precedence, fail-closed `repos` guard), reports the effective
@@ -10,6 +11,12 @@ settings, then runs the orchestrator: `--once` for a single sweep (pair it
10
11
  with `--dry-run -v` to print every decision and its reasons without touching
11
12
  GitHub or the task graph), otherwise `run_forever`. Config errors still surface
12
13
  here, at startup, with a clean exit code.
14
+
15
+ Startup is the ONE place that may do I/O before the loop begins, and it does
16
+ exactly one call: under `release_feed: bow` with no explicit `bow_owners`, the
17
+ daemon asks Alissa who its token is and makes itself the feed authority. That
18
+ call failing is fatal — the daemon exits rather than starting up unable to tell
19
+ its own containers from a stranger's.
13
20
  """
14
21
 
15
22
  from __future__ import annotations
@@ -17,9 +24,10 @@ from __future__ import annotations
17
24
  import argparse
18
25
  import logging
19
26
  import sys
27
+ from dataclasses import replace
20
28
  from pathlib import Path
21
29
 
22
- from .alissa_client import AlissaError
30
+ from .alissa_client import AlissaClient, AlissaError
23
31
  from .config import (
24
32
  FEED_BOW,
25
33
  RELEASE_FEEDS,
@@ -84,9 +92,10 @@ def build_parser() -> argparse.ArgumentParser:
84
92
  dest="repos",
85
93
  action="append",
86
94
  metavar="OWNER/REPO",
87
- help="watch this repo; repeatable. When given, REPLACES the config "
88
- "`repos` list. The allowlist is fail-closed: an empty allowlist is a "
89
- "fatal startup error, not a no-op.",
95
+ help="watch this repo; repeatable, and one flag may carry several "
96
+ "entries separated by `|` (the daemon-family convention) or `,`. When "
97
+ "given, REPLACES the config `repos` list. The allowlist is fail-closed: "
98
+ "an empty allowlist is a fatal startup error, not a no-op.",
90
99
  )
91
100
  over.add_argument("--label", metavar="LABEL", help="issue label released work carries")
92
101
  over.add_argument("--poll-interval", type=int, metavar="SECONDS")
@@ -118,10 +127,12 @@ def build_parser() -> argparse.ArgumentParser:
118
127
  metavar="ACTOR_ID",
119
128
  help="bow feed only: trust bodies of work OWNED by this Alissa actor "
120
129
  "as release feeds; repeatable. When given, REPLACES the config "
121
- "`bow_owners` list. Fail-closed like --repo: `--release-feed bow` with "
122
- "an empty owner list is a fatal startup error, because being SHARED a "
123
- "body of work is not a claim to drive this daemon. An actor id is the "
124
- "`ownerActorId` on the container's row in GET /v1/bodies-of-work.",
130
+ "`bow_owners` list. OPTIONAL left unset, the daemon resolves its own "
131
+ "token's actor at boot and trusts only its own containers; give this "
132
+ "flag only when the containers belong to someone else. Being SHARED a "
133
+ "body of work is never a claim to drive this daemon either way. Must be "
134
+ "an actor ID (the `ownerActorId` on the container's row in "
135
+ "GET /v1/bodies-of-work) — a username or display name is refused.",
125
136
  )
126
137
  over.add_argument(
127
138
  "--scan-fallback-interval",
@@ -196,6 +207,57 @@ def resolve_config(args: argparse.Namespace) -> Config:
196
207
  return Config.build(workspace_root, file_data, overrides_from(args))
197
208
 
198
209
 
210
+ def resolve_feed_authority(config: Config, alissa: AlissaClient) -> Config:
211
+ """Decide WHOSE bodies of work drive this daemon, and say so out loud.
212
+
213
+ Two answers, one of them free:
214
+
215
+ * `bow_owners` **set** — the operator named the authority explicitly. That
216
+ is the multi-operator case: the containers belong to an actor this token
217
+ is not. Nothing is resolved, nothing is called.
218
+ * `bow_owners` **unset** — the authority is **self**. The token already
219
+ carries an identity, so `GET /v1/ping` answers the question the operator
220
+ would otherwise have hand-copied out of the API, and the daemon trusts
221
+ exactly its own containers.
222
+
223
+ The whoami call is **fatal on failure**, deliberately, and this is the whole
224
+ fail-closed argument in one place. The alternative — carry on with an empty
225
+ authority — is technically safe (nothing would be trusted, so nothing would
226
+ release) but it is safe *silently*: the operator sees a daemon that runs and
227
+ releases nothing, which is exactly what a correctly configured daemon with
228
+ an empty queue looks like. Dying names the reason while someone is watching.
229
+ Note the property being protected is not "self is trusted" but "only a
230
+ KNOWN actor is trusted" — an unanswered whoami leaves that unknown, and no
231
+ default can fill it in.
232
+
233
+ Under `scan` this is a no-op: that feed never reads a Body of Work, so it
234
+ never needs an identity, and a daemon that only scans must not fail to boot
235
+ because an endpoint it does not use was unreachable.
236
+ """
237
+ if config.release_feed != FEED_BOW:
238
+ return config
239
+
240
+ if config.bow_owners:
241
+ log.info(
242
+ "bow feed authority: explicit (%s)", ", ".join(config.bow_owners)
243
+ )
244
+ return config
245
+
246
+ identity = alissa.whoami() # fatal on failure — see main()
247
+ log.info("bow feed authority: self (%s)", identity.actor_id)
248
+ if identity.display_name:
249
+ # Logged as context, never compared: a display name is renameable, which
250
+ # is why `config.bow_owners` refuses to hold one.
251
+ log.debug(
252
+ "resolved from this token's identity: %r", identity.display_name
253
+ )
254
+ # The id comes from the API, so it is the authority on its own shape and is
255
+ # NOT put through `config`'s actor-id check — that check exists to catch a
256
+ # human typing a username, and applying it here would let a future id format
257
+ # break a daemon that had been given no configuration to be wrong about.
258
+ return replace(config, bow_owners=(identity.actor_id,))
259
+
260
+
199
261
  def log_effective_config(config: Config) -> None:
200
262
  """The resolved settings, defaults and all. INFO carries the decisions an
201
263
  operator needs; -v (DEBUG) shows the full surface."""
@@ -240,14 +302,52 @@ def main(argv: "list[str] | None" = None) -> int:
240
302
  try:
241
303
  config = resolve_config(args)
242
304
  log.info("workspace: %s", config.workspace_root)
243
- log_effective_config(config)
244
- except (FileNotFoundError, ValueError) as exc:
305
+ # One client, shared with the orchestrator: the boot-time whoami and
306
+ # every later task-graph call must speak as the same actor, or the
307
+ # authority resolved here would not be the one doing the reading.
308
+ alissa = AlissaClient(base=config.alissa_endpoint)
309
+ except (FileNotFoundError, ValueError, AlissaError) as exc:
310
+ # `AlissaError` is here to keep main()'s exit contract whole, not because
311
+ # anything in this block raises one today (the client's constructor only
312
+ # reads ALISSA_BASE / ALISSA_API_TOKEN). Exit 2 means "never started" and
313
+ # every other way of never starting prints one line; a lone path that
314
+ # answers with a traceback and exit 1 — the code that means "started,
315
+ # then a pass failed" — would misreport itself the day the constructor
316
+ # grows any validation. Plain text, deliberately: the bow-specific advice
317
+ # belongs only to the call that can actually earn it, below.
245
318
  print(f"config error: {exc}", file=sys.stderr)
246
319
  return 2
247
320
  except KeyboardInterrupt:
248
321
  return 0
249
322
 
250
- orchestrator = Orchestrator(config)
323
+ # Deliberately its OWN try, holding exactly one call. The handler below
324
+ # names the bow feed, and `resolve_feed_authority` is the only thing here
325
+ # that can fail for that reason — under `scan` it returns before making any
326
+ # call at all. A wider block would let some unrelated Alissa failure be
327
+ # reported as an authority problem, pointing a scan-only operator at a knob
328
+ # that does nothing in their feed mode.
329
+ try:
330
+ config = resolve_feed_authority(config, alissa)
331
+ except AlissaError as exc:
332
+ # The bow feed could not learn who this token is. Fatal by design (see
333
+ # resolve_feed_authority): booting on an unknown identity would mean a
334
+ # daemon that looks healthy and silently releases nothing. Same exit
335
+ # code as a config error — it is the same class of failure, a daemon
336
+ # that never started, and the operator's fix is a token or an explicit
337
+ # bow_owners rather than anything the loop could retry.
338
+ print(
339
+ f"config error: could not resolve the bow feed authority — {exc}. "
340
+ f"Check ALISSA_API_TOKEN and the endpoint, or set bow_owners "
341
+ f"explicitly to name the actor(s) that own the feed bodies of work.",
342
+ file=sys.stderr,
343
+ )
344
+ return 2
345
+ except KeyboardInterrupt:
346
+ return 0
347
+
348
+ log_effective_config(config)
349
+
350
+ orchestrator = Orchestrator(config, alissa=alissa)
251
351
  try:
252
352
  if args.once:
253
353
  orchestrator.poll_once()
@@ -94,6 +94,18 @@ Both BOW endpoints require the daemon actor to be a **collaborator** on the
94
94
  Body of Work (otherwise 403 ``FORBIDDEN`` on attach/detach) -- an operator setup
95
95
  step, documented in the README. orcloop never attaches: attaching is the
96
96
  operator's trigger, detaching is orcloop's terminal release act.
97
+
98
+ WHO AM I (O8)
99
+ =============
100
+ ``GET /v1/ping`` -- ``{"pong", "timestamp", "userId", "actorId",
101
+ "displayName"}`` -- is the token's identity endpoint, and it is the ONLY one:
102
+ ``/v1/me``, ``/v1/whoami``, ``/v1/actors/me``, ``/v1/auth/me``, ``/v1/user(s)/me``,
103
+ ``/v1/identity``, ``/v1/actor``, ``/v1/session``, ``/v1/profile`` all answer 404
104
+ ``"The requested endpoint does not exist."``. Its OpenAPI summary states the
105
+ contract outright: *"Verify the token and return the acting identity."* That is
106
+ what lets ``bow_owners`` default to the daemon's own actor instead of making the
107
+ operator hand-copy an id -- see ``whoami`` and the boot resolution in
108
+ ``__main__.resolve_feed_authority``.
97
109
  """
98
110
 
99
111
  from __future__ import annotations
@@ -189,6 +201,21 @@ class BowMember:
189
201
  status: str
190
202
 
191
203
 
204
+ @dataclass(frozen=True)
205
+ class Identity:
206
+ """Who this token acts as, from ``GET /v1/ping``.
207
+
208
+ `actor_id` is the load-bearing field: it is the same opaque id that appears
209
+ as `ownerActorId` on a Body of Work, so it can be compared directly against
210
+ a container's owner. `user_id` and `display_name` are carried for logging
211
+ only -- a display name is renameable and must never reach an authority
212
+ check (see `config.bow_owners`)."""
213
+
214
+ actor_id: str
215
+ user_id: str = ""
216
+ display_name: str = ""
217
+
218
+
192
219
  @dataclass(frozen=True)
193
220
  class Criterion:
194
221
  id: str
@@ -317,6 +344,37 @@ class AlissaClient:
317
344
 
318
345
  # --- adapter surface ---------------------------------------------------
319
346
 
347
+ def whoami(self) -> Identity:
348
+ """The identity this token acts as (`GET /v1/ping`).
349
+
350
+ The `bow` feed's authority defaults to this actor, so the call is on the
351
+ BOOT path and its failure is fatal there -- never softened into a
352
+ warning. A daemon that could not learn who it is cannot tell its own
353
+ containers from a stranger's, and the only safe answers are "refuse to
354
+ start" or "trust nobody"; refusing is the loud one, so that is what
355
+ `__main__.resolve_feed_authority` does with what this raises.
356
+
357
+ A 2xx with no `actorId` is a contract violation rather than a missing
358
+ resource, so it surfaces as the base `AlissaError` -- the same way
359
+ `get_task` treats a response with no task object. `AlissaAuthError`
360
+ (a bad or unset token) and `AlissaTransient` (the endpoint was
361
+ unreachable) come through the shared taxonomy unchanged.
362
+ """
363
+ payload = self._request("GET", "/v1/ping")
364
+ row = payload if isinstance(payload, dict) else {}
365
+ actor_id = row.get("actorId")
366
+ if not isinstance(actor_id, str) or not actor_id.strip():
367
+ raise AlissaError(
368
+ 200,
369
+ f"GET /v1/ping returned no actorId (got {payload!r}) — the "
370
+ f"token's acting identity could not be determined",
371
+ )
372
+ return Identity(
373
+ actor_id=actor_id.strip(),
374
+ user_id=str(row.get("userId") or ""),
375
+ display_name=str(row.get("displayName") or ""),
376
+ )
377
+
320
378
  def list_marked_committed_tasks(self, marker: str = RELEASE_MARKER) -> list[MarkedTask]:
321
379
  """Committed tasks carrying the release `marker`, with descriptions.
322
380
 
@@ -21,6 +21,20 @@ watch nothing, but boot anyway), an empty orcloop allowlist is **fatal at
21
21
  startup** — the orchestrator releases work into `alissa:develop` issues and
22
22
  closes the loop on merge, so it never runs against an unbounded repo set.
23
23
 
24
+ REPOS SEPARATOR (O7). A repo list written as one string — `ALISSA_ORC_REPOS`,
25
+ a `--repo a/b|c/d` flag, or a single-element list in the config file — is split
26
+ on **both `|` and `,`**, mixed freely. `|` is the daemon **family convention**
27
+ (devloop's `ALISSA_DEV_REPOS` has always been pipe-separated); comma stays
28
+ accepted for backward compatibility with every orcloop release up to 0.3.0.
29
+ The convention matters because the operator points several Railway services at
30
+ ONE list through a variable reference — `ALISSA_ORC_REPOS=${{dev.ALISSA_DEV_REPOS}}`
31
+ — which only works if both daemons read the same separator. Entries are
32
+ whitespace-stripped, empties from splitting are dropped (so a trailing
33
+ separator is harmless), duplicates collapse, and each surviving entry must look
34
+ like `owner/repo` (a bare word or a pasted URL is a fatal startup error naming
35
+ the offending entry). An allowlist that is empty *after* parsing stays fatal —
36
+ fail-closed is unchanged.
37
+
24
38
  The marker contract (`Autonomous-Dev` / `Autonomous-Scope` issue-body
25
39
  trailers) is parsed in `markers.py`; the keys that name those trailers live
26
40
  here (`marker_key`, `scope_key`) so an operator can rename them per-deployment.
@@ -36,21 +50,35 @@ RELEASE FEED (O6). `release_feed` selects where release candidates come from:
36
50
  O(total BOW membership). `scan_fallback_interval` keeps a low-frequency full
37
51
  trailer scan running underneath as a visibility safety net.
38
52
 
39
- The `bow` feed has a **second** fail-closed allowlist, `bow_owners`, and the two
40
- answer different questions. A container's title says which repo it targets, so
41
- `repos` bounds *where* a release lands. Nothing in a title says who created the
53
+ The `bow` feed has a **second** allowlist, `bow_owners`, and the two answer
54
+ different questions. A container's title says which repo it targets, so `repos`
55
+ bounds *where* a release lands. Nothing in a title says who created the
42
56
  container — and the daemon lists Bodies of Work it merely **collaborates** on,
43
57
  which any actor in the tenant can arrange without an acceptance step on this
44
58
  side. `bow_owners` names the actor id(s) whose containers may drive the daemon,
45
- so "whose work reaches an autonomous pipeline" is an operator decision rather
46
- than a consequence of being shared something. Empty is fatal under
47
- `release_feed: bow`, exactly as an empty `repos` is fatal always.
59
+ so "whose work reaches an autonomous pipeline" is a decided question rather
60
+ than a consequence of being shared something.
61
+
62
+ FEED AUTHORITY (O8). `bow_owners` is an OPTIONAL OVERRIDE, not required setup.
63
+ Left unset it resolves at boot to the **token's own actor** — the daemon asks
64
+ `GET /v1/ping` who it is and trusts only its own containers (see
65
+ `__main__.resolve_feed_authority`, which is also where a failed whoami is
66
+ fatal). Set it only for a multi-operator deployment, where the containers are
67
+ owned by someone other than the actor the daemon's token acts as. That the
68
+ default is self does not soften the gate: an unresolvable identity refuses to
69
+ boot rather than trusting everything, so the fail-closed property `bow_owners`
70
+ was introduced for is unchanged — only the hand-copying is gone.
71
+
72
+ Entries are actor **ids** and nothing else; a username or display name is
73
+ rejected here, naming it (see `_validate_actor_id` for why a name is both a
74
+ spoof surface and a silent one).
48
75
  """
49
76
 
50
77
  from __future__ import annotations
51
78
 
52
79
  import json
53
80
  import os
81
+ import re
54
82
  from dataclasses import dataclass, field
55
83
  from pathlib import Path
56
84
  from typing import Any, Mapping
@@ -91,9 +119,52 @@ RELEASE_FEEDS = (FEED_SCAN, FEED_BOW)
91
119
  # Keys carrying a boolean; env strings for these need real parsing (bool("false")
92
120
  # is True, which would silently invert the setting).
93
121
  _BOOL_KEYS = frozenset({"dry_run", "close_loop_enabled"})
94
- # Keys carrying a list of strings; an env value is comma-separated.
122
+ # Keys carrying a list of strings. In an env value they are separator-delimited;
123
+ # `repos` accepts the family's `|` as well as `,` (see REPO_SEPARATORS), every
124
+ # other list key is comma-separated.
95
125
  _LIST_KEYS = frozenset({"repos", "bow_owners"})
96
126
 
127
+ # The separators a one-string repo list may use, in preference order. `|` is the
128
+ # daemon-family convention (devloop's ALISSA_DEV_REPOS), which is what lets one
129
+ # Railway variable reference feed both daemons; `,` is what orcloop shipped
130
+ # through 0.3.0 and stays accepted. Mixing them in one value is fine.
131
+ REPO_SEPARATORS = ("|", ",")
132
+ _REPO_SPLIT_RE = re.compile("[" + re.escape("".join(REPO_SEPARATORS)) + "]")
133
+
134
+ # What an allowlist entry must look like once split: exactly one `/`, a GitHub
135
+ # owner (alphanumerics and hyphens, not leading/trailing) and a repo name
136
+ # (alphanumerics, `.`, `_`, `-`) that is neither all dots nor `.git`-suffixed
137
+ # (in any casing, matching the hint branch below — spelled as a character class
138
+ # rather than re.IGNORECASE, which would also relax the two strict classes) —
139
+ # both are names GitHub itself refuses. Deliberately strict enough to reject
140
+ # the mistakes a separator change invites — a bare word (`widgets`, which names
141
+ # no owner) and a pasted URL or git remote (`https://github.com/acme/widgets`,
142
+ # `git@github.com:acme/widgets.git`), each of which would otherwise become an
143
+ # allowlist row the daemon silently never matches.
144
+ #
145
+ # Every exclusion here is one spelling GitHub refuses that the character class
146
+ # would otherwise accept, so this is the line new ones get added to; the hint
147
+ # table in `_validate_repo_entry` is where each earns its own message.
148
+ _REPO_NAME_RE = re.compile(
149
+ r"^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?"
150
+ r"/(?!\.+$)(?!.*\.[Gg][Ii][Tt]$)[A-Za-z0-9._-]+$"
151
+ )
152
+
153
+ # What an Alissa actor id looks like: a 32-character opaque handle of lower-case
154
+ # letters and digits (`j5706fv7xe5jy1k5wdwzacab9s8axcd2`). Every id the API mints
155
+ # — actors, users, tasks, bodies of work — has this shape, and `bow_owners` is
156
+ # checked against it so a username or display name cannot be mistaken for one.
157
+ # The check is SHAPE only: it says "this could be an id", never "this actor
158
+ # exists" (nothing short of a lookup could, and a wrong-but-well-formed id
159
+ # already fails loudly as a feed nobody owns). If the API ever mints a different
160
+ # shape, this constant and the pattern below are the two lines to widen.
161
+ ACTOR_ID_LENGTH = 32
162
+ _ACTOR_ID_ALPHABET = frozenset("abcdefghijklmnopqrstuvwxyz0123456789")
163
+ # `\Z`, not `$`: `$` also matches just before a trailing newline, so
164
+ # `"<id>\n"` would pass. Callers strip, but a gate that only holds because its
165
+ # caller normalised is not a gate.
166
+ _ACTOR_ID_RE = re.compile(rf"^[a-z0-9]{{{ACTOR_ID_LENGTH}}}\Z")
167
+
97
168
  MIN_POLL_INTERVAL = 10 # the GitHub search API allows 30 req/min
98
169
 
99
170
  DEFAULT_ALISSA_ENDPOINT = "https://api.alissa.app"
@@ -134,6 +205,168 @@ def _string_list(value: Any, key: str) -> tuple[str, ...]:
134
205
  return tuple(value)
135
206
 
136
207
 
208
+ def split_repo_entries(raw: str) -> list[str]:
209
+ """Split one repo-list string into entries on `|` and/or `,`.
210
+
211
+ Whitespace around an entry is stripped and empties are dropped, so a
212
+ trailing separator, a doubled one, and `a/b | c/d` all behave. This is the
213
+ ONE place the separator set is applied: the library's env layer, the CLI
214
+ flag, the config file, and the container's `render_config.py` all route
215
+ through it, so no surface can end up accepting a different syntax.
216
+
217
+ Splitting only — shape validation is `normalize_repos`, because the config
218
+ file may also hand over an already-split JSON list.
219
+ """
220
+ return [entry for part in _REPO_SPLIT_RE.split(raw) if (entry := part.strip())]
221
+
222
+
223
+ def normalize_repos(values: tuple[str, ...] | list[str]) -> tuple[str, ...]:
224
+ """Turn the raw `repos` layer value into the validated allowlist.
225
+
226
+ Every element is itself split (an entry may carry a whole separated list —
227
+ that is exactly what `ALISSA_ORC_REPOS` and a `--repo a/b|c/d` flag look
228
+ like), duplicates collapse case-insensitively keeping the operator's first
229
+ spelling (GitHub names are case-insensitive, and `watches` matches them
230
+ that way — a repeat is a duplicated GitHub search term, not a second lane),
231
+ and each survivor is validated as `owner/repo`.
232
+
233
+ Emptiness is NOT judged here: `Config.build` owns the fail-closed guard so
234
+ the operator gets its one canonical message.
235
+ """
236
+ out: list[str] = []
237
+ seen: set[str] = set()
238
+ for value in values:
239
+ for entry in split_repo_entries(value):
240
+ _validate_repo_entry(entry)
241
+ if (key := entry.casefold()) not in seen:
242
+ seen.add(key)
243
+ out.append(entry)
244
+ return tuple(out)
245
+
246
+
247
+ def _validate_repo_entry(entry: str) -> None:
248
+ """Reject anything that is not an `owner/repo` name, naming the entry.
249
+
250
+ Fail loudly rather than carry it: an unmatchable allowlist entry is not
251
+ inert, it is a repo the operator believes is watched and is not.
252
+ """
253
+ if _REPO_NAME_RE.match(entry):
254
+ return
255
+ if (
256
+ "://" in entry
257
+ or "@" in entry
258
+ or ":" in entry
259
+ or entry.casefold().startswith(("github.com/", "www."))
260
+ ):
261
+ # Covers a pasted browser URL, a scheme-less `github.com/...`, and an
262
+ # SSH remote (`git@github.com:acme/widgets.git`) — which has no `://`
263
+ # and only one `/`, so without the `@`/`:` tests it would fall through
264
+ # to the bare message.
265
+ hint = " — that looks like a URL or a git remote; use only the " \
266
+ "`owner/repo` part"
267
+ elif entry.casefold().endswith(".git") and "/" in entry:
268
+ # Ordered AFTER the URL/remote branch so the two messages COMPOSE: a
269
+ # full remote keeps "use only the `owner/repo` part", and the
270
+ # `acme/widgets.git` that following that advice yields gets the
271
+ # remaining half of the fix here. Without this branch, the first
272
+ # message walks the operator from a loud abort into a silent no-match.
273
+ hint = (" — drop the trailing `.git`; that is the clone URL's suffix, "
274
+ "not part of the repo name")
275
+ elif "/" not in entry:
276
+ hint = " — no `/`, so it names no owner"
277
+ elif entry.count("/") > 1:
278
+ hint = " — too many `/`; an entry is one owner and one repo"
279
+ else:
280
+ hint = ""
281
+ raise ValueError(
282
+ f"repos entry {entry!r} is not an owner/repo name{hint}. Entries look "
283
+ f"like `acme/widgets` and are separated by `|` (the daemon-family "
284
+ f"convention) or `,`, e.g. \"acme/widgets|acme/gadgets\"."
285
+ )
286
+
287
+
288
+ def normalize_bow_owners(values: tuple[str, ...] | list[str]) -> tuple[str, ...]:
289
+ """Turn the raw `bow_owners` layer value into the validated authority list.
290
+
291
+ Entries are whitespace-stripped, empties dropped (so a trailing comma is
292
+ harmless, matching `repos`), duplicates collapsed — EXACTLY, with no
293
+ casefolding, because actor ids are opaque: two spellings that differ in case
294
+ are two different strings on the wire, and folding them would silently widen
295
+ the match. Every survivor must look like an actor id.
296
+
297
+ Emptiness is fine here and means "not overridden": the boot path resolves
298
+ the authority to this token's own actor (see the module docstring). There is
299
+ no fail-closed guard left in `build` for it — an empty list still admits
300
+ nothing (`trusts_feed_owner` returns False for every owner), so the property
301
+ is preserved by the comparison itself rather than by refusing to start.
302
+ """
303
+ out: list[str] = []
304
+ seen: set[str] = set()
305
+ for value in values:
306
+ entry = value.strip()
307
+ if not entry:
308
+ continue
309
+ _validate_actor_id(entry)
310
+ if entry not in seen:
311
+ seen.add(entry)
312
+ out.append(entry)
313
+ return tuple(out)
314
+
315
+
316
+ def _validate_actor_id(entry: str) -> None:
317
+ """Reject anything that is not an Alissa actor id, naming the entry.
318
+
319
+ Ownership is checked against a container's `ownerActorId`, which is opaque
320
+ and immutable. A username or display name is neither: it is operator-facing
321
+ text the owning account can CHANGE, so an allowlist written in those terms
322
+ grants authority to whoever holds the name when the check runs rather than
323
+ to the actor the operator meant — the reason ids are the only accepted
324
+ spelling.
325
+
326
+ Worse, getting it wrong would not even be loud. Nothing on the wire ever
327
+ equals a name, so a name in this list simply matches no container, and the
328
+ `bow` feed's symptom is an empty queue — indistinguishable from "no work
329
+ attached yet". Failing here, at boot, with the offending entry named, is the
330
+ only point where the mistake is visible.
331
+ """
332
+ if _ACTOR_ID_RE.match(entry):
333
+ return
334
+ lowered = entry.lower()
335
+ if _ACTOR_ID_RE.match(lowered):
336
+ # The one near-miss with a mechanical fix, so it gets its own message
337
+ # instead of being described. NOT lowered silently: ids are opaque, and
338
+ # quietly rewriting one makes a config stop meaning what it says.
339
+ hint = f" — actor ids are lower-case; did you mean {lowered!r}?"
340
+ else:
341
+ # LENGTH FIRST. Both problems are reported when both are present, but a
342
+ # short name is a length problem before it is an alphabet one: telling
343
+ # someone their 8-character username "contains 'ADHMORTZ'" buries the
344
+ # useful observation under a rendering of its own character set.
345
+ problems = []
346
+ if len(entry) != ACTOR_ID_LENGTH:
347
+ problems.append(
348
+ f"it is {len(entry)} characters, not {ACTOR_ID_LENGTH}"
349
+ )
350
+ offending = "".join(sorted(set(entry) - _ACTOR_ID_ALPHABET))
351
+ if offending:
352
+ problems.append(
353
+ f"it contains {offending!r}, which no actor id does"
354
+ )
355
+ hint = " — " + " and ".join(problems)
356
+ if offending:
357
+ hint += "; that looks like a username or a display name"
358
+ raise ValueError(
359
+ f"bow_owners entry {entry!r} is not an Alissa actor id{hint}. Use the "
360
+ f"opaque id — the `ownerActorId` on the container's row in "
361
+ f"GET /v1/bodies-of-work, or `actorId` from GET /v1/ping — never a "
362
+ f"username or display name: names are renameable, so an ownership "
363
+ f"check against one is a spoof surface, and since no id on the wire "
364
+ f"ever equals a name it would fail as a silently empty feed rather "
365
+ f"than an error. Leave bow_owners unset to trust this token's own "
366
+ f"actor, which needs no id at all."
367
+ )
368
+
369
+
137
370
  def _env_bool(env_name: str, raw: str) -> bool:
138
371
  value = raw.strip().lower()
139
372
  if value in _ENV_TRUE:
@@ -164,6 +397,11 @@ def env_overrides(environ: Mapping[str, str] | None = None) -> dict[str, Any]:
164
397
  raw = env[env_name]
165
398
  if key in _BOOL_KEYS:
166
399
  out[key] = _env_bool(env_name, raw)
400
+ elif key == "repos":
401
+ # Separator-tolerant (`|` and `,`); shape-validated in `build`
402
+ # alongside the file and CLI layers, so one bad entry reports the
403
+ # same way wherever it came from.
404
+ out[key] = tuple(split_repo_entries(raw))
167
405
  elif key in _LIST_KEYS:
168
406
  out[key] = tuple(
169
407
  item for part in raw.split(",") if (item := part.strip())
@@ -181,7 +419,9 @@ class Config:
181
419
  # Fail-closed allowlist of owner/repo. Empty is REJECTED at build (see
182
420
  # build's guard) — the orchestrator never runs against an unbounded set.
183
421
  # The dataclass default is () only to mark "unset"; a real Config always
184
- # carries at least one repo.
422
+ # carries at least one repo. Entries reach `build` as `|`- and/or
423
+ # `,`-separated text from any layer and land here split, validated,
424
+ # de-duplicated, and in the operator's own casing (see `normalize_repos`).
185
425
  repos: tuple[str, ...] = ()
186
426
 
187
427
  label: str = "alissa:develop"
@@ -222,13 +462,16 @@ class Config:
222
462
  bow_prefix: str = "autodev: "
223
463
 
224
464
  # WHOSE Bodies of Work may act as a release feed: the Alissa actor id(s)
225
- # allowed to OWN a feed container. Fail-closed like `repos` empty is
226
- # REJECTED at build when `release_feed` is `bow` (see build's guard). The
227
- # title tells the daemon which repo a container targets; it says nothing
228
- # about who created it, and the daemon lists containers it merely
229
- # collaborates on, which anyone in the tenant can arrange unilaterally.
230
- # So provenance is an operator decision, made here. Ids are opaque and
465
+ # allowed to OWN a feed container. The title tells the daemon which repo a
466
+ # container targets; it says nothing about who created it, and the daemon
467
+ # lists containers it merely collaborates on, which anyone in the tenant can
468
+ # arrange unilaterally. So provenance is decided here. Ids are opaque and
231
469
  # compared EXACTLY (unlike `repos`, which casefolds GitHub names).
470
+ #
471
+ # Empty means "not overridden", NOT "trust everyone": the boot path resolves
472
+ # it to this token's own actor and dies if it cannot (O8), and until it does
473
+ # an empty list matches no owner at all. An explicit list is the
474
+ # multi-operator case — containers owned by an actor this token is not.
232
475
  bow_owners: tuple[str, ...] = ()
233
476
 
234
477
  # The `bow`-feed safety net: every this-many seconds, ALSO run the full
@@ -270,6 +513,13 @@ class Config:
270
513
  payload does not state cannot be attributed, and an unattributable feed
271
514
  is exactly what `bow_owners` exists to refuse. Exact comparison — Alissa
272
515
  actor ids are opaque, so normalising them would only widen the match.
516
+
517
+ Fail-closed on an EMPTY authority as well, which is what makes the O8
518
+ default safe to resolve outside this class: before boot fills the list
519
+ in (or when something skipped that step), every owner is untrusted and
520
+ nothing is a feed. The failure mode of a missing authority is therefore
521
+ an inert daemon, never an open one — boot's job is only to make that
522
+ state loud instead of quiet.
273
523
  """
274
524
  return bool(actor_id) and actor_id in self.bow_owners
275
525
 
@@ -313,7 +563,12 @@ class Config:
313
563
  # The env layer wins over everything, including a value set on the CLI.
314
564
  raw.update(env_overrides(environ))
315
565
 
316
- repos = _string_list(raw.get("repos", cls.repos), "repos")
566
+ # Split-then-validate: whichever layer won, an entry may still be a
567
+ # whole `a/b|c/d` list (that is what one Railway variable reference
568
+ # looks like), so normalize before the fail-closed check — a value of
569
+ # only separators parses to nothing and must read as "empty", not as
570
+ # one bogus repo.
571
+ repos = normalize_repos(_string_list(raw.get("repos", cls.repos), "repos"))
317
572
  if not repos:
318
573
  # Fail-closed: the orchestrator spawns develop work and closes the
319
574
  # loop on merge. An empty allowlist would let anyone who can label
@@ -321,7 +576,8 @@ class Config:
321
576
  raise ValueError(
322
577
  "repos allowlist is empty — the orchestrator is fail-closed and "
323
578
  "refuses to start with no repos. Set `repos` in the config file, "
324
- "pass one or more --repo flags, or set ALISSA_ORC_REPOS."
579
+ "pass one or more --repo flags, or set ALISSA_ORC_REPOS "
580
+ "(entries separated by `|`, or `,`)."
325
581
  )
326
582
 
327
583
  interval = int(raw.get("poll_interval", cls.poll_interval))
@@ -365,25 +621,15 @@ class Config:
365
621
  "turn every Body of Work into a release feed"
366
622
  )
367
623
 
368
- bow_owners = _string_list(raw.get("bow_owners", cls.bow_owners), "bow_owners")
369
- if release_feed == FEED_BOW and not bow_owners:
370
- # Fail-closed, the `repos` guard's twin. `repos` bounds WHERE the
371
- # orchestrator writes; this bounds WHOSE work drives it. The feed
372
- # lists Bodies of Work the daemon merely collaborates on, and being
373
- # made a collaborator takes no acceptance step on this side, so
374
- # without an owner allowlist "shared with the daemon and titled for
375
- # a watched repo" is enough to get a task rendered into an issue in
376
- # that repo and handed to the develop pipeline. Only fatal for the
377
- # `bow` feed: `scan` never reads a Body of Work at all.
378
- raise ValueError(
379
- "bow_owners is empty and release_feed is 'bow' — the release "
380
- "feed is fail-closed and refuses to start without the actor "
381
- "id(s) allowed to own a feed Body of Work (being shared one is "
382
- "not a claim to drive this daemon). Set `bow_owners` in the "
383
- "config file, pass one or more --bow-owner flags, or set "
384
- "ALISSA_ORC_BOW_OWNERS. An actor id is the `ownerActorId` on "
385
- "the container's row in GET /v1/bodies-of-work."
386
- )
624
+ # Shape-validated in EVERY feed mode, not just `bow`. The value is inert
625
+ # under `scan`, but a malformed id that boots quietly today is a daemon
626
+ # that refuses to start on the unrelated day the operator flips the feed
627
+ # over and this is the layer that knows the entry came from a human.
628
+ # Empty stays legal: the boot path resolves the authority to this
629
+ # token's own actor (O8), so `bow_owners` is an override, not setup.
630
+ bow_owners = normalize_bow_owners(
631
+ _string_list(raw.get("bow_owners", cls.bow_owners), "bow_owners")
632
+ )
387
633
 
388
634
  fallback = int(raw.get("scan_fallback_interval", cls.scan_fallback_interval))
389
635
  if fallback < 0:
@@ -67,9 +67,10 @@ titled `autodev: <owner>/<repo>` (`bow_prefix`).
67
67
  trailer logs a warning and the BOW wins (never a silent retarget).
68
68
  `Autonomous-Scope` is unchanged: still read from the task body, still the
69
69
  intra-repo parallel-release glob list.
70
- * **Only an ALLOWLISTED OWNER's Body of Work is a feed** (`bow_owners`, empty
71
- is fatal under this feed). The listing includes containers merely SHARED with
72
- the daemon, and sharing is unilateral -- so a feed-shaped title is a target
70
+ * **Only an ALLOWLISTED OWNER's Body of Work is a feed** (`bow_owners`, which
71
+ defaults at boot to the daemon's OWN actor and is fatal if that cannot be
72
+ resolved -- O8). The listing includes containers merely SHARED with the
73
+ daemon, and sharing is unilateral -- so a feed-shaped title is a target
73
74
  declaration, never a provenance claim. `bow_prefix` and `repos` bound which
74
75
  repo a release lands in; they say nothing about whose task gets there, and an
75
76
  allowlisted repo is where the daemon's writes are most trusted. A prefix-
@@ -609,6 +610,26 @@ class Orchestrator:
609
610
  surfaced: set[int] = set()
610
611
  unresolved: set[str] = set()
611
612
 
613
+ if not self.config.bow_owners:
614
+ # Not reachable through the CLI: boot resolves the authority (the
615
+ # token's own actor, or the operator's explicit list) and refuses to
616
+ # start if it cannot. Kept because it is the feed's central property
617
+ # and it must hold for an Orchestrator built any other way -- a
618
+ # test, an embedder, a future entry point. `trusts_feed_owner`
619
+ # already refuses every owner on an empty list, so the release
620
+ # behavior here is unchanged; what this adds is the LOUDNESS boot
621
+ # would have provided. The pass is INCOMPLETE, so the fallback sweep
622
+ # does not go on to report every marked task as "the feed did not
623
+ # surface this" -- the feed did not run.
624
+ log.error(
625
+ "release_feed is 'bow' but no feed authority is set — no body "
626
+ "of work can be authoritative, so nothing is released this "
627
+ "pass. Startup normally resolves this to the token's own actor; "
628
+ "set bow_owners explicitly if the feed containers are owned by "
629
+ "another actor."
630
+ )
631
+ return FeedPass(results, surfaced, unresolved, complete=False)
632
+
612
633
  try:
613
634
  bows = self.alissa.list_bodies_of_work()
614
635
  except AlissaError as exc:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera