Skip to content

Buddy: his ChatGPT sign-in and state through his own Hermes gateway, in the Launchpad and lazurio buddy (DEV-6676 PR 1, F63) - #635

Merged
immakermatty merged 14 commits into
mainfrom
agent/DEV-6676-buddy-gateway-signin
Oct 25, 2026
Merged

immakermatty merged 14 commits into
mainfrom
agent/DEV-6676-buddy-gateway-signin

Conversation

@immakermatty

@immakermatty immakermatty commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

Buddy signs in to ChatGPT with his own account, separately from the agents (root decision 0210). So far only the interim installer could do that. It started a temporary hermes dashboard on 127.0.0.1:9119 for up to four hours, printed instructions and stopped. Anyone on the machine could read that dashboard's session token from GET /. Začínáme and Nastavení → Buddy need Buddy's sign-in and his state from the Launchpad, and operators need them without that dashboard.

This is PR 1 of Buddy's onboarding (plan DEV-6676; the shaping for the Buddy lead is in the Lazurio root at drafts/DEV-6676-buddy-onboarding/SHAPING.md: §3.1 variant A, §3.2 to §3.5, §3.10 and PR 1 in §5). It is the Platform's side only: the client, the sign-in, the state, the routes and the CLI. Persona, UI, profiles and Telegram come in later PRs.

Shaping decisions this follows

  • Variant A (§3.1). The Launchpad becomes one more local client of the hermes-gateway.service that already runs Buddy, the way Hermes Desktop is. It takes a one-time ticket from the gateway's owner-only control socket for each call and sends it to the gateway's loopback dashboard API. Rejected:
    • B, a temporary dashboard on :9119: its token reaches every local account.
    • D, reading Hermes's files.
    • C, the CLI on its own. Here the CLI is a client on top of A.
  • One truth (§2). Buddy's sign-in lives in Hermes's auth store, and only Hermes's own OAuth worker writes it. The Platform starts, polls, cancels and signs out only through the routes Hermes's own LOGIN uses. It never reads the store and never runs hermes auth add.
  • The state is a pure function of one fresh reading (§3.4). Per the lead's instruction there is no "attention" state until openai-codex status: a machine-readable relogin_required state (refused refresh vs never signed in) hermes-agent#16.

What changes

  • src/buddy/hermes-gateway.ts, the local client, ported from Desktop's local-gateway.ts and Hermes's own discovery code.
    • Control socket. ~/.hermes/gateway.sock, or a pointer to hermes-gw-<sha256(home)[:16]>/control.sock. The socket, the pointer and its directory are refused if they are a link, belong to another account or are open to others. The home is refused if anyone else can write to it.
    • Handshake. identify first, then one session-ticket per request.
    • API origin. Must be exactly http://127.0.0.1:<port> or http://[::1]:<port>.
    • HTTP. Uses node:http without an agent. It sends no Origin, no profile query and nothing beyond the ticket, Host and Accept.
    • Limits and failures. Each call has a deadline and a bounded answer size, and every failure is one closed reason. After a refused ticket the client tries once more with a new ticket. Nothing else is ever sent twice.
  • src/buddy/sign-in.ts: start, poll, cancel and sign-out through /api/providers/oauth/openai-codex/*.
    • One sign-in at a time. The holder gets an opaque 32-hex handle; Hermes's session id stays on the server.

    • A sign-in starts only on a Hermes that keeps its OAuth cancellation contract (Lazurio/hermes-agent#17).

      • The contract: Hermes's sweep, like its cancel, marks a session cancelled under the lock its worker checks before it saves.
      • The gateway advertises it in identify: lazurio-oauth-cancel-contract-v1 listed in capabilities while the gateway is ready, as #17 at d1cf093e defines it in gateway/lazurio_oauth_contract.py. The constant oauthCancelContract names it.
      • Without the contract, start answers 409 {kind: "blocked", reason: "hermes-outdated"} and starts nothing, so no worker exists that could save late. The CLI says "Buddy needs an update before he can be signed in here."
      • Status, and a sign-out without a held sign-in, still work.
    • A sign-in ends only once Hermes's worker certainly can no longer save one (Pablo's three reviews: fixed in 74766d59, db21135c and 3663ab07). That is the case when:

      • its poll reports an ending (approved, expired, denied, error);
      • its cancel answers {ok: true} for exactly that session;
      • Hermes no longer holds the session (poll 404, or cancel {ok: false, message: "session not found"}) and the instance saying so keeps the contract, on the same instance or another one after a restart.

      Anything else (a lost answer, HTTP 500, a timeout, an unexpected shape, or "not found" without the contract) keeps the sign-in held and addressable. A replacement start, a cancel or a sign-out that needed it to end then changes nothing. Hermes decides when a sign-in expires; the Launchpad keeps no time bound.

    • The code and OpenAI's page pass only in their exact shapes (the existing codexDeviceCode and codexDevicePage).

    • Hermes's own error text never leaves. Answers carry only closed reasons: expired, refused, lost, failed.

  • src/buddy/status.ts: works, waits or unknown, with unknown + not-running when the gateway is not running.
  • Routes (src/launchpad/buddy-routes.ts):
    • GET /api/buddy returns {kind: "buddy", state, reason?, signIn: {state}, locale}.
    • POST /api/buddy/sign-in/start|poll|cancel and POST /api/buddy/sign-out.
    • Without a recorded buddyOrigin every route answers 404 buddy-absent.
    • Every POST, poll included, goes through the hosted same-origin rule.
    • 413/415/405/400 follow the house style.
    • Start answers 409 hermes-outdated without the contract.
    • A start, cancel or sign-out that needs a held sign-in to end, while it may still complete, answers 409 {kind: "blocked", reason: "sign-in-running"} (or "hermes-outdated") and changes nothing.
    • They answer 503 buddy-unavailable when the gateway is unreachable.
    • The journal (scope: "buddy-sign-in") records only the operation, the outcome and the reason.
  • CLI, on the same core: lazurio buddy status --json, lazurio buddy sign-in start --json and lazurio buddy sign-out --json, listed in buddyHelp and the dispatch.
  • Decision record F63 at the end of docs/decisions.md: the A–D table, the security rules, what was recorded and what derived, the mutation checks and honest limits. It names hermes-agent#16, hermes-agent#15 and installer change I-a.

Deviations from the brief, for the lead to confirm

  1. No separate sign-in poll and sign-in cancel CLI commands. sign-in start holds the sign-in the way lazurio tools login does:

    • The code is printed on the first line, then each change on its own line until the sign-in ends.
    • Ctrl-C cancels it on Hermes.

    A later separate process could only reach Hermes's session through its id or the code written to a file. §3.10 keeps both out of every file and answer. The held sign-in still replaces signin-start.sh and signin-wait.sh.

  2. The home's mode rule follows Desktop, not "no group or other access". The socket, pointer and pointer directory get no access for anyone else. The home must not be writable by anyone else, but reading and searching are allowed (HERMES_HOME_MODE 0750/0701), as Desktop and Hermes allow. Those bits grant nothing against the 0600 socket.

  3. node:http, not fetch (the shaping's §3.3 says fetch). Bun's fetch uses HTTP_PROXY even for a loopback address and keeps it for the rest of the process (observed with Bun 1.4.2), so it would hand the ticket to a proxy. Hermes's own test client turns proxies off for this exact call (trust_env=False).

What does not change

  • No UI or messages.ts text, no persona or profile, no Telegram, no change to lazurio buddy chat. buddy-chat.test.ts changes one line only: status is no longer an unknown command.
  • Nothing reads or writes auth.json or .env.
  • Nothing starts, restarts or configures a Hermes process.
  • Nothing is cancelled when the Launchpad stops. Hermes's worker still saves a sign-in the person finishes.

Tests

  • A fake gateway built from recorded answers (tests/fixtures/fake-hermes-gateway.ts, hermes-gateway-v0.22.4-lazurio.1.json).
    • Every answer was recorded from the real gateway at v0.22.4-lazurio.1: python -m gateway.run with a private home, the same way Hermes's own test_native_http_auth.py runs it.
    • Recorded: identify (ready, starting, draining); tickets and the socket's refusals; provider cards signed out and signed in (written by Hermes's own _save_codex_tokens); a real start against OpenAI's public device-code endpoint, cancelled at once, with its poll, the cancel and the poll's 404; both sign-outs (200, 409); the 401s; the 403 for a profile query; the pointer fallback.
    • Every live value is replaced by a synthetic one. The other poll endings and the start's refusals are derived from Hermes's code and marked as such.
  • The real gateway, end to end. This revision's client and sign-in core also ran on macOS against the real v0.22.4-lazurio.1 gateway. The socket was reached through the pointer, because the home was longer than a socket path allows. Results:
    • The state read waits, then works once Hermes's own writer had saved a sign-in.
    • A start through node:http returned a real code that passed codexDeviceCode, with OpenAI's page.
    • Its poll, the cancel, the poll after it (none) and both sign-outs behaved as expected.
    • Re-run on the first fix: the real gateway's cancel confirmation passed the strict check (cancelled).
    • Re-run on the second fix without contacting OpenAI: status and sign-out still work through the real gateway. (The recorded tag doesn't advertise the contract, so on it the gate keeps Buddy's sign-in from starting.)
  • tests/buddy-hermes-gateway.test.ts:
    • One ticket per call, spent once, with the exact request lines and headers: no Origin, no profile query.
    • No proxy: the call runs in a separate process with HTTP_PROXY set, because Bun's fetch keeps the first proxy it sees and that would leak into later tests.
    • A refused ticket is replaced once; a failed write is never sent twice.
    • Every non-loopback origin is refused before a ticket is minted.
    • Each malformed identify or ticket answer is refused.
    • Path rules for the home, socket, pointer and its directory: links, another account's path, open modes, missing files. A symlinked home is followed.
    • Deadlines and size limits.
  • tests/buddy-sign-in.test.ts:
    • Code and page accepted only in their exact shapes.
    • Hermes errors reduced to closed reasons and every poll ending covered. A restart in the middle keeps the sign-in until Hermes's 404 reports it lost.
    • One sign-in at a time, including two started at once.
    • Cancel and sign-out.
    • Every cancel Hermes does not confirm: HTTP 500, a confirmation of another session, another ok: false, a 404, no JSON, a refused ticket, a timeout.
      • A replacement start starts nothing, and the sign-in before stays addressable.
      • An explicit cancel answers blocked.
      • A sign-out signs nothing out, even while the uncancelled sign-in gets approved.
      • Expiry and an unexpected poll answer end only after a confirmed cancel.
      • A malformed start stays held without a holder.
      • A restart's "session not found" ends each of these.
    • The contract. The fake honours cancellation marks: a worker saves only an unmarked session, and its sweep (collect) marks one only under the contract, as Pill test: wait for the check to settle, not for the request count #17 does.
      • Without the contract no sign-in starts, so a save delayed beyond any time can't happen. Status and sign-out still work.
      • With the contract, a swept session ends at once by "not found" or the poll 404, for poll, replacement, cancel and sign-out. Its worker trying to save afterwards saves nothing.
      • A gateway restarted without the contract keeps the sign-in held (hermes-outdated); another instance with the contract ends it as lost.
      • A sign-in still pending hours past its code's end waits for Hermes, and nothing is cancelled for it.
    • Journal and answers free of code, ticket and session id.
    • The status truth table.
  • tests/launchpad-buddy.test.ts:
    • The routes on a hosted personal Environment.
    • The same-origin rule refuses every POST that lacks it or comes from Buddy's own origin, before Hermes is asked.
    • Poll accepts only POST.
    • 404 buddy-absent on a personal Environment without Buddy, on an Organization's Environment and on a workstation.
    • 503 when the gateway is unreachable; 409 blocked for a start, a cancel and a sign-out while Hermes does not confirm a cancel (sign-in-running); 409 hermes-outdated for a start without the contract, while status and sign-out are still answered.
    • Journal and answers free of every secret.
  • tests/buddy-cli.test.ts: the CLI's JSON and exit codes, a held sign-in until it ends, Ctrl-C cancelling it or explaining on stderr why it could not, the start refused in words without the contract, and usage errors.
  • Gateway client tests read the contract from identify (the member holding the value or listing it; no other value counts) and never send a call that requires it to a gateway without it.
  • Mutation checks. Each of 50 guards was removed or loosened one at a time, and every one made a test fail; F63 lists them all. They include the reviewed faults (any cancel answer taken as an end; "not found" taken as an end without the contract) and 8 checks on the gate. The bound's checks went with the bound. I confirmed separately that the fetch mutation fails the proxy test alone and that removing serialization fails the two-tab test.
  • Commands run (results on the PR's head):

Risks and limits

Review follow-up

  • Pablo's P1 at ab519598 (a cancel's failure was taken as success): fixed in 74766d59, with F63 updated in 6d3f3378. The fault no longer reproduces:

    • A session DELETE intercepted with HTTP 500 now makes a replacement start answer blocked, and the gateway keeps only the first session.
    • The old handle still polls pending.
    • signOut() answers blocked.
    • Approving the retained session signs Buddy in without anyone having been told he was signed out.
  • Pablo's second P1 at 6d3f3378 (a "session not found" or poll 404 taken as proof of an end, although Hermes's 15-minute clean-up doesn't mark the session cancelled): answered in db21135c with a time bound and an instance fast path. Both were superseded by the third fix below.

  • Pablo's third P1 at b7cca474 (the 1223 s bound isn't an upper bound, and another instance isn't proof that the old worker is gone): fixed in 3663ab07, with F63 updated in afc3b30a, and the field and the closed window confirmed in 3c65e7a7. The Hermes side is the separate fork PR Pill test: wait for the check to settle, not for the request count #17 at d1cf093e.

Merge

Merging waits until the Platform freeze ends, after v0.1.11 ships. This PR does not touch any live Environment.

🤖 Generated with Claude Code

immakermatty and others added 5 commits October 25, 2026 02:04
…he owner-only control socket (DEV-6676, F63)

The Launchpad becomes one more local client of the hermes-gateway.service that already runs
Buddy, as Hermes Desktop is: `identify` over the control socket in ~/.hermes (or the pointer to
hermes-gw-<sha256(home)[:16]>/control.sock), then one `session-ticket` per request, sent to the
gateway's loopback dashboard API as X-Hermes-Gateway-Ticket with no Origin and no profile query.
The socket, the pointer and its directory are refused when linked, another account's or open to
others, the home when anyone else may write to it; the API origin must be exactly
http://127.0.0.1:<port> or http://[::1]:<port>. HTTP goes through node:http without an agent:
Bun's fetch takes HTTP_PROXY even for a loopback address and keeps it for the process, which
would hand the ticket to a proxy. Deadlines (5 s socket, 15 s HTTP), bounded answers, closed
failure reasons, and one new ticket after a refused one; nothing else is sent twice.

Tests run against a stand-in gateway whose answers were recorded from the real gateway of
Lazurio/hermes-agent v0.22.4-lazurio.1 (live values replaced by synthetic ones). Nothing uses
the client yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… sign-in at a time (DEV-6676, F63)

Start, poll, cancel and sign-out call the dashboard routes Hermes's own LOGIN uses
(/api/providers/oauth/openai-codex/start, /poll/<id>, DELETE /sessions/<id>, DELETE
/openai-codex), so only Hermes's OAuth worker writes Buddy's sign-in into his auth store; never
`hermes auth add`, and the Platform never reads that store. One sign-in at a time in memory, a
new start cancelling the old one on Hermes first; the holder gets an opaque 32-hex handle while
Hermes's session id stays in the process. The code and OpenAI's page pass only in their exact
shapes (codexDeviceCode, codexDevicePage), Hermes's own words become closed reasons (expired,
refused, lost, failed), and the journal names only the operation, the outcome and the reason.

Buddy's state is a pure function of one fresh reading: works, waits, or unknown (not-running
when the gateway does not run). There is no "needs attention" until Lazurio/hermes-agent#16
gives Hermes's card a relogin state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dy status, sign-in start and sign-out (DEV-6676, F63)

GET /api/buddy answers {kind: "buddy", state, reason?, signIn: {state}, locale}; POST
/api/buddy/sign-in/start, /poll, /cancel and /api/buddy/sign-out drive the sign-in core. They
exist only where the recorded entry names Buddy's origin (404 buddy-absent elsewhere, a
workstation included). Every POST, the poll included, passes the hosted same-origin rule of the
admission, so Buddy's own frame cannot call them; each takes exactly its JSON fields (413, 415,
405, 400 as the house style), and start and sign-out answer 503 buddy-unavailable without his
gateway. The page that uses them comes later.

The CLI shares the core: `lazurio buddy status`, `lazurio buddy sign-in start`, which holds the
sign-in as `lazurio tools login` does (the code on its first line, each change after it, Ctrl-C
cancels it on Hermes), and `lazurio buddy sign-out`, all JSON on stdout and only where the
Folder's binding records Buddy. It replaces the interim installer's signin-start.sh and
signin-wait.sh for operators. buddy-chat's usage test no longer treats `status` as unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…client of Buddy's Hermes gateway

Records variant A of the DEV-6676 shaping (Buddy's gateway with a one-time ticket) against a
transient dashboard on :9119, a CLI alone and reading Hermes's files, with the security rules,
the client's path and origin rules, the sign-in's closed reasons, the state's truth table, the
routes and the CLI, what was recorded from the real gateway and what is derived, the mutation
checks and the honest limits. Names the gaps outside the Platform: Lazurio/hermes-agent#16
(a relogin state), #15 (Lazurio's words for a lost sign-in) and the installer's change I-a
(Buddy's gateway runs before his sign-in). Proposed for Matěj's decision at review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Platform's own client and sign-in core ran on macOS against the real gateway of
Lazurio/hermes-agent v0.22.4-lazurio.1, its control socket through the pointer: waits, then works
with a sign-in Hermes's own writer saved; a start through node:http whose real code passed
codexDeviceCode, its poll, the cancel and both sign-outs. F63's "Verified by" now says so; nothing
else changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@immakermatty
immakermatty marked this pull request as ready for review October 25, 2026 01:25

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@immakermatty — P1, changes requested on exact head ab519598b2d33a3dd7399d8b345b7212313d688a.

src/buddy/sign-in.ts:161-176,293-325: cancelOnHermes() discards both transport failures and HTTP failures, and its callers clear current and proceed as though Hermes had cancelled. If the cancel route returns HTTP 500 (or times out after the gateway was reached), a second start() creates another live Hermes session and forgets the old handle. signOut() then reports signed-out after a 200/409 from the provider route even though the uncancelled OAuth worker can later approve its code and write a fresh credential. Explicit cancel() likewise reports cancelled without proof. This breaks F63's one-sign-in-at-a-time and no-sign-in-after-sign-out promises, not just the local journal.

Reproduced in a disposable exact-head checkout with the recorded fake gateway: intercept session DELETE with HTTP 500; first and replacement start() both return pending, the gateway retains two sessions and the old handle returns none; signOut() returns signed-out; approving the retained old session makes the provider card signed in again. Keep the session addressable and do not report a successful cancellation/replacement/sign-out unless Hermes positively confirms the session is ended (or proves it absent); handle indeterminate failures closed. Cover failed cancellation on replacement, explicit cancel, sign-out, and expiry/malformed-response paths with gateway-fixture tests.

The existing five focused Buddy files pass 48/48 here under Bun 1.4.4; their happy-path cancellations do not exercise this failure. Gateway protocol, loopback/proxy rule, route admission, status table, and F63's recorded-versus-derived fixture were inspected against the pinned Hermes tag. CI is green, but neither it nor the listed mutation checks closes this case. Review only; no merge or release.

immakermatty and others added 2 commits October 25, 2026 02:49
…or a sign-out without it changes nothing (#635 review)

Pablo's review of #635 found that the cancel of a running sign-in discarded every failure: a
replacement start, an explicit cancel and a sign-out then went on as if Hermes had cancelled, so
an uncancelled OAuth worker could still save a sign-in after the Launchpad said it was replaced,
cancelled or signed out. A sign-in now stays held and addressable until Hermes says it ended: its
poll answers an ending or a 404, or its cancel answers {ok: true} for exactly that session (Hermes
marks it cancelled under the lock its worker saves under) or {ok: false, message: "session not
found"}, its recorded answer for a session it does not hold. Anything else proves nothing:
- a replacement start starts nothing and the sign-in before stays the holder's;
- an explicit cancel answers blocked (sign-in-running), or unavailable with the gateway's reason;
- a sign-out signs nothing out until the held sign-in is confirmed cancelled;
- a poll the gateway does not answer keeps the sign-in; after a restart Hermes's 404 ends it;
- an answer of no known shape, and a sign-in Hermes still calls waiting 90 s past its code's end
  (the worker's last round), end only once a cancel is confirmed;
- a start of no known shape stays held without a holder until its cancel is confirmed.

The routes answer 409 {kind: "blocked", reason: "sign-in-running"} for such a start, cancel or
sign-out, and 503 buddy-unavailable for a cancel without the gateway; the CLI's Ctrl-C says when
the cancel was not confirmed. Gateway-fixture tests cover every unconfirmed cancel (HTTP 500,
another session's confirmation, another ok:false, a 404, no JSON, a refused ticket, a timeout) on
replacement, cancel, sign-out, expiry and malformed answers; 12 more mutation checks fail a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…635 review)

F63's sign-in, routes and CLI points now say when a sign-in ends (Hermes's poll ending or 404,
its confirmed cancel or its "session not found"), that a replacement, a cancel and a sign-out
without that change nothing (409 blocked, 503 without the gateway), and how expiry and answers of
no known shape end. The alternatives name the rejected first head and a local-clock expiry; the
verified and mutation lists and the honest limits follow the new tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@immakermatty

Copy link
Copy Markdown
Contributor Author

P1, src/buddy/sign-in.ts: a cancel's failure was taken as success. Fixed. The PR head is now 6d3f3378184b6d19c086bb1d98a5d9d3e988f1eb: the fix is in 74766d59, and F63 is updated in 6d3f3378.

When a sign-in counts as ended. Only on Hermes's word, checked at v0.22.4-lazurio.1. One of:

  • its poll reports an ending (approved, expired, denied, error) or a 404;
  • its cancel answers {ok: true, session_id: <that session>}. Hermes sets cancelled under _oauth_sessions_lock, and its worker re-checks that flag under the same lock right before _save_codex_tokens, so nothing is saved after a confirmed cancel;
  • its cancel answers {ok: false, message: "session not found"}. That is cancel_oauth_session's answer for a session it does not hold: one cancelled before, one a restart dropped, or one _gc_oauth_sessions removed. It comes as HTTP 200, never a 404, and both shapes are in the recorded fixture.

Anything else proves nothing. A lost or refused answer, a timeout, an HTTP error or an answer of no known shape all keep the sign-in held and addressable:

  • Replacement start: starts nothing and answers 409 {kind: "blocked", reason: "sign-in-running"}, or 503 buddy-unavailable with the gateway's reason. The old handle still polls pending.
  • Explicit cancel: answers blocked (or unavailable) and keeps the sign-in.
  • Sign-out: signs out only after the held sign-in's cancel is confirmed; otherwise it answers blocked and the provider DELETE is never sent.
  • Poll the gateway does not answer: keeps the sign-in. After a restart, Hermes's 404 ends it as lost.
  • Unexpected poll answer, or still waiting 90 s past the code's end (the 90 s cover the worker's last round): ends only after a confirmed cancel.
  • Start of no known shape: stays held without a holder until its cancel is confirmed, so nothing else starts or signs out in the meantime.

Your reproduction, now. With the session DELETE intercepted as HTTP 500:

  • the replacement start answers blocked, and the gateway keeps only the first session;
  • the old handle polls pending;
  • signOut() answers blocked, and the provider card stays signed in;
  • approving the retained session reaches the holder as signed-in, and nobody was ever told Buddy was signed out.

Tests added (gateway fixture):

  • every unconfirmed cancel answer: HTTP 500, another session's confirmation, {ok: true} without an id, another ok: false, a 404, no JSON, a refused ticket, a timeout;
  • each of those on replacement, explicit cancel and sign-out (sign-out also with the gateway draining), plus expiry and malformed poll and start answers;
  • a restart's "session not found" ending each of them;
  • the routes' 409 and journal;
  • the CLI's Ctrl-C reporting an unconfirmed cancel.

Mutation checks. 45/45 fail a test. That includes this fault itself (any cancel answer taken as an end) and 11 further guards, each removed alone:

  • a confirmation of another session accepted;
  • any ok: false taken as absent;
  • the replacement, the cancel or the sign-out going ahead without confirmation (one mutation each);
  • a gateway failure dropping the sign-in;
  • a malformed start forgotten;
  • no grace for Hermes's last round;
  • expiry, or an unexpected answer, ending without confirmation (one mutation each);
  • blocked answered as 200.

Checks run:

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — A session not found response does not always prove that the Codex worker cannot still save credentials. At pinned Hermes tag v0.22.4-lazurio.1, hermes_cli/web_routers/oauth.py:796-800 GC removes any session older than 15 minutes from _oauth_sessions without setting sess["cancelled"]. The worker retains that same dict (:303-345), and _codex_cancelled (:177-181) checks only its flag. Its 15-minute poll deadline begins after session creation/user-code acquisition (:91-102, :245, :308-323); a last poll or token exchange may be in flight when another /start runs GC. The old session then yields poll 404 or DELETE HTTP 200 {ok:false,message:"session not found"} (:873-920), while the worker can still reach _save_codex_tokens. In this PR, src/buddy/sign-in.ts:235-241,331-332 treats either answer as proof of ending; replacement and sign-out may therefore proceed before the old worker saves again.

I executed the actual tagged _gc_oauth_sessions and _codex_cancelled functions against an old, still-pending worker-held session: GC removed it from the map, but the worker reference reported cancelled=False. Please fix the pinned Hermes provider contract so GC sets cancelled=True under _oauth_sessions_lock before removing a live session (and test the in-flight worker/save race), then update the fixture/pin and requalify the platform flow. A missing session must not count as a safe ending until that contract is true.

The original DELETE→500 reproduction now passes locally: replacement blocked with one session retained, the old handle remained pending, sign-out did not run, and approval subsequently reported signed-in (3 targeted tests passed; CI checks on this SHA green). This is a distinct boundary at the 15-minute cleanup. Review only; no merge while main is frozen.

immakermatty and others added 2 commits October 25, 2026 03:36
…nstance or after its worker's last round (#635 review)

Pablo's second review of #635: at Lazurio/hermes-agent v0.22.4-lazurio.1, Hermes's 15-minute
clean-up (_gc_oauth_sessions, oauth.py:796-800) removes a session without marking it cancelled,
while its worker keeps the same record and checks only that mark (:177-181, :303-345). A poll's
404 or a cancel's {ok: false, message: "session not found"} therefore no longer counts as an end
on its own. It ends a sign-in only:
- from another gateway instance: each client answer now carries the instance_id its ticket was
  bound to, and a sign-in keeps the one that started it; Hermes runs one runtime per process, so
  a new instance means the old worker's process is gone;
- or once the worker's last chance to save has passed, on the Launchpad's monotonic clock from the
  start's answer: expires_in (900 s), one more poll_interval, one poll request (4 x 15 s), the token
  exchange (3 tries, 183 s), the save's lock (15 s) and a 60 s margin, 1223 s for the recorded
  answer (600 s assumed for an interval of no known shape).
Until then a replacement, a cancel and a sign-out answer 409 blocked sign-in-finishing, a poll keeps
waiting, and the CLI says so in words on stderr. The expiry cancel waits for the same bound instead
of a separate 90 s grace, so a last-round approval is never cut off. Gateway-fixture tests cover the
clean-up's window in the same instance (before and after the bound), another instance, the
interval, an unknown interval and a wall clock set forward; 9 more mutation checks fail a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or after the worker's bound (#635 review)

F63's sign-in point now says when a session Hermes no longer holds can no longer be saved: another
gateway instance answers, or the worker's last round has passed, with every constant of that bound
and its source line at v0.22.4-lazurio.1. The routes and CLI points name sign-in-finishing, the
alternatives add the rejected "not found as proof", waiting for the fork's fix and a wall-clock
bound, and the honest limits state the clock, timeout and process assumptions. The fork's own fix
(the clean-up marks a session cancelled) is a separate Lazurio/hermes-agent change; F63 does not
depend on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@immakermatty

Copy link
Copy Markdown
Contributor Author

P1, "session not found" taken as proof that a sign-in ended. Fixed. The PR head is now 181cb6a26478519cbcb1a0e60ccb4cef26169702: the fix is in db21135c, and F63 is updated in 181cb6a2.

The Hermes-side fix is a separate fork PR. In it, _gc_oauth_sessions marks a session cancelled under _oauth_sessions_lock before removing it. It goes into the Lazurio/hermes-agent release with #11, #13 and #14 and is handled by another agent. The Platform rule below does not depend on it, and no fork capability is read.

The rule now. A poll 404, or a cancel's {ok: false, message: "session not found"}, ends a sign-in only when one of these holds:

  • Another gateway instance answers. Every client answer now carries the instance_id of the identify its ticket was bound to, and a sign-in keeps the instance that started it. Hermes runs one runtime per process (hermes_cli/gateway.py:4630, which ends with os._exit, gateway/run.py:5198), so a new instance means the old worker's process is gone.

  • The worker's last chance to save has certainly passed. This is measured on the Launchpad's monotonic clock (performance.now()) from the moment the start's answer arrived. The worker sets its user code, which starts its deadline, before _start_codex_device_code answers (:518-543). The bound adds up:

    Part Duration Source at the tag
    expires_in (_CODEX_EXPIRES_IN) 900 s :48, :316; the deadline is set at :245
    one more poll_interval asleep 5 s recorded :250-253
    one poll request (httpx 15 s for each of acquiring a connection, connecting, writing and reading) 60 s _codex_client, :184-192
    the token exchange (_codex_post: three tries, 1 s and 2 s apart) 183 s :195-213, :332
    the save's wait for the auth store lock (AUTH_LOCK_TIMEOUT_SECONDS) 15 s auth_constants.py:52 via auth.py:847-861
    margin 60 s
    total for the recorded answer 1223 s

    A start whose lifetime or interval had no known shape counts 3600 s and 600 s instead.

Until one of those holds, a replacement start, a cancel and a sign-out answer 409 {kind: "blocked", reason: "sign-in-finishing"} and change nothing. A poll keeps the sign-in, and the CLI says so in words on stderr. The expiry cancel now waits for the same bound instead of a separate 90 s grace, so it never cuts off an approval in the last round. A confirmed {ok: true} cancel still ends a sign-in at once, and the earlier DELETE-500 tests are unchanged.

Tests added (gateway fixture). The fake's new collect models the tagged clean-up: the session leaves the map, is not cancelled, and its worker can still save. Covered:

  • not-found and poll 404 in the same instance before the bound: blocked (replacement, cancel, sign-out) up to the last millisecond while the worker saves meanwhile, and the poll keeps the sign-in;
  • from the bound on: both end it;
  • after an instance change: an end at once, for poll, replacement, cancel and sign-out;
  • the bound pinned at 1223 s, longer with a 200 s interval, 600 s assumed for an interval of no known shape, and a wall clock set forward an hour proving nothing;
  • the routes' 409 sign-in-finishing, and the CLI's finishing words.

Mutation checks. 54/54 fail a test. The 9 new ones are:

  • "not found" counted as an end whatever the instance and time (this fault);
  • the instance not compared;
  • the bound not used;
  • the poll's 404 outside the rule;
  • the last round, or only the interval, left out of the bound (one mutation each);
  • the wall clock used for the bound;
  • the starting instance not recorded;
  • a short interval assumed when none is known.

F63 now records:

  • the rule, with every constant and its source line;
  • three new rejected alternatives: "not found" as proof, waiting for the fork's fix or reading a capability from it, and a wall-clock bound;
  • honest limits for the clock, timeout and process assumptions;
  • a pointer to the fork's own fix.

Checks run:

…ermes-agent#17

The fork change that marks a collected sign-in session cancelled under its
lock is now a real pull request, approved for the release with #11, #13 and
#14; F63 named it only generically. The rule itself is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@immakermatty — P1, changes requested on exact head b7cca474c9748f2a62664b5cfc4a324f11193497.

The addition is arithmetically 1223 s for the recorded 900 s + 5 s + 60 s + 183 s + 15 s + 60 s, but it is not an upper bound on the worker's last save at the pinned Hermes tag. In hermes_cli/web_routers/oauth.py:339-345, the worker enters _profile_scope(session_profile) before the cancellation/save lock. _profile_scope waits on a process-wide threading.RLock with no timeout (hermes_cli/web_server_profiles.py:239,262-273); neither that wait nor filesystem writes in _save_codex_tokens (auth_codex.py:174-214) are bounded by AUTH_LOCK_TIMEOUT_SECONDS. httpx's 15 s timeout is per I/O phase, not a whole-request wall-time cap. A collected worker can finish token exchange, wait on the profile lock past the Platform's finishedBy, then acquire it and save.

At that time src/buddy/sign-in.ts:273-295,472-490 treats the same-instance {ok:false,message:"session not found"} as ended and reports sign-out while the old uncancelled worker may later save credentials. In an isolated exact-head checkout, a one-off fake-gateway probe with a collected worker delayed until after the 1223 s bound reproduced signOut() -> signed-out, then a late fake.approve(oldId) -> signedIn=true (1/1). This is a timing model, not a claim that a real gateway was held for 20 minutes. The tagged code supplies the unbounded wait that makes the model possible. Existing tests approve the collected worker before the bound and do not cover this ordering.

The instance fast path also needs a stronger process-death guarantee: gateway/run.py:5219-5241 releases the runtime lock before os._exit, so a new instance can in principle become ready while the old process and its daemon OAuth worker still exist. A distinct instance_id proves a new runtime, not by itself the old worker's death; F63 itself notes this overlap.

Please make a missing-session answer safe by a qualified Hermes cancellation contract (e.g. ship/pin and verify #17's cleanup under the save lock), or provide a truly enforced worker-completion/process-death bound. Keep the held sign-in and 409 until that proof exists, and add a regression with a save delayed beyond the proposed bound. The CLI's stderr and route's 409 behavior, and exact-head focused tests (50/50), lint, typecheck and public check pass locally; current CI is green. Those gates do not establish this liveness guarantee. Review only; no merge or release.

immakermatty and others added 3 commits October 25, 2026 04:27
…ation contract; "not found" ends one only there (#635 review)

Pablo's third review of #635: the 1223 s bound was not an upper bound at Lazurio/hermes-agent
v0.22.4-lazurio.1. The worker waits without a timeout for the process-wide profile lock before its
save, the save's writes are not bounded, and httpx's 15 s is per I/O phase. A swept worker could
therefore save after the bound. The rule now rests on Hermes's cancellation contract,
Lazurio/hermes-agent#17: the sweep, like the cancel, marks a session cancelled under the lock its
worker checks before it saves.
- The gateway advertises the contract in identify. The client reads it into every endpoint and
  answer; a call that requires it is never sent to a gateway without it ("outdated"). The member
  and value sit in one constant, oauthCancelContract, pending the fork's exact naming.
- Without the contract a start starts nothing: 409 {kind: "blocked", reason: "hermes-outdated"},
  and the CLI says "Buddy needs an update before he can be signed in here." Status, and a sign-out
  without a held sign-in, still work.
- With it, a cancel's "session not found" and a poll's 404 end a sign-in at once, on the same
  instance or on another one after a restart. Without it they keep the sign-in held
  (hermes-outdated).
- The time bound, its constants, the monotonic clock and the expiry cancel are gone. Hermes ends a
  sign-in itself, and the Launchpad waits for that.

The fake gateway honours cancellation marks: a worker saves only an unmarked session, and its
sweep marks one only under the contract. Tests cover the gate in the core, route and CLI, a swept
worker trying to save after "not found" (it cannot), a downgrade across a restart, and another
instance. Mutation checks now cover the gate (8 of them); the bound's checks are gone with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntract, Lazurio/hermes-agent#17 (#635 review)

F63's sign-in point now gates the start on the contract the gateway advertises (hermes-outdated
otherwise) and counts "session not found" and a poll's 404 as an end only under it. The bound and
its constants are gone. The alternatives add the rejected time bound (Pablo's third review: the
profile lock's wait, the save's writes and httpx's per-phase timeout are unbounded) and the
instance fast path, and record that reading the fork's capability is now adopted. The honest
limits name the residual window at a restart, where Hermes releases its runtime lock before
os._exit, and state that Buddy's status is read live. #17 is listed among the gaps outside the
Platform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and F63 records the restart window as closed (#635 review)

#17 at d1cf093e lists lazurio-oauth-cancel-contract-v1 in identify's capabilities, after upstream's
three, while the gateway is ready (gateway/lazurio_oauth_contract.py). That is what
oauthCancelContract already held, so its pending note goes and its comment cites the fork's
definition.

#17 also has every gateway exit go through release_gateway_runtime_lock, which first marks every
pending sign-in cancelled under _oauth_sessions_lock and only then releases the runtime lock; a
save still holding that lock after 1 s keeps the runtime lock until the process exits. F63 now says
the restart window is closed by #17, so a successor's "not found" is as safe as the gateway's own.
It names the two cases #17 does not cover, neither of which applies to Buddy's single-profile
gateway with openai-codex: a secondary profile a multi-profile gateway stops serving
(release_profile_home), and the Nous poller's config adjustment after a save made before the
"not found".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@immakermatty

immakermatty commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor Author

P1: the 1223 s bound is not an upper bound, and another instance is not proof that the old worker is gone. Fixed. The PR head is now 3c65e7a7ae278d7ac2f3b1ab644b0dee8e16eed0: the fix is in 3663ab07, F63 is updated in afc3b30a, and 3c65e7a7 confirms the field and the closed window.

The time bound is gone. Its constants, the monotonic clock, the expiry cancel and their tests are removed. As your review found, at v0.22.4-lazurio.1 there is no bound that holds:

  • the worker waits on _profile_scope's process-wide RLock with no timeout before the save lock;
  • the writes in _save_codex_tokens are not bounded;
  • httpx's 15 s applies per I/O phase, not per request.

F63 records the bound as a rejected alternative, citing your third review.

The rule now rests on Hermes's cancellation contract, which is the Hermes side of this fix: the separate fork PR Lazurio/hermes-agent#17 at d1cf093e. The sweep, like the cancel, marks a session cancelled under the lock the worker checks before it saves, and so does a stopping gateway before it releases its runtime lock.

  • Gate. The gateway advertises the contract in identify: lazurio-oauth-cancel-contract-v1 listed in capabilities while the gateway is ready, as #17 at d1cf093e defines it in gateway/lazurio_oauth_contract.py. The client reads it into every endpoint and answer, and a call that requires it is never sent to a gateway without it.
    • Without the contract, start answers 409 {kind: "blocked", reason: "hermes-outdated"} and nothing starts, so no worker exists that could save late.
    • The CLI says "Buddy needs an update before he can be signed in here."
    • Status, and a sign-out without a held sign-in, still work: the provider DELETE does not depend on the session contract.
  • With the contract. A cancel's {ok: false, message: "session not found"}, or a poll 404, ends the sign-in at once on the same instance.
  • Instance change. On another instance that keeps the contract, "not found" ends it as lost. On an instance without the contract (a downgrade), the sign-in stays held (hermes-outdated).
  • The restart window is closed by Pill test: wait for the check to settle, not for the request count #17. At the tag, Hermes releases its runtime lock before os._exit (gateway/run.py:5219-5241). Under the contract, every exit goes through release_gateway_runtime_lock, which first marks every pending sign-in cancelled under _oauth_sessions_lock and only then releases the runtime lock. A save still holding that lock after 1 s keeps the runtime lock until the process exits, so the guarantee doesn't depend on the 1 s. A successor's "not found" is therefore as safe as the gateway's own.
    • F63 names the two cases Pill test: wait for the check to settle, not for the request count #17 doesn't cover: a secondary profile that a multi-profile gateway stops serving (release_profile_home), and the Nous poller's configuration adjustment after a save made before the "not found".
    • Neither applies to Buddy's single-profile gateway with openai-codex.
  • Unchanged. A confirmed {ok: true} cancel still ends a sign-in, the DELETE-500 tests are unchanged, and Hermes alone decides when a sign-in expires.

Regressions (gateway fixture). The fake now honours cancellation marks: a worker saves only an unmarked session, and its sweep marks one only under the contract.

  • Without the contract, no sign-in starts, so a save delayed beyond any time bound cannot happen; status and sign-out still work.
  • With the contract, a swept worker that tries to save after "not found" saves nothing. This is covered for poll, replacement, cancel and sign-out.
  • A restart onto a Hermes without the contract, and another instance with the contract.
  • The gate's closed reason in the core, the route (409, journal) and the CLI (JSON and plain words).
  • The client reads the contract from identify (the member holding or listing exactly that value) and refuses to send a call that requires it to a gateway without it.

Mutation checks. 50/50 fail a test. The 8 on the gate:

  • the start not gated;
  • the contract assumed rather than read from identify;
  • the client's refusal removed;
  • "not found" counted without the contract;
  • the poll 404 counted without the contract;
  • any advertised value accepted;
  • hermes-outdated answered as 503;
  • the CLI's words for it.

Checks run:

…t a local path

Variant A (the Launchpad as a local client of Buddy's Hermes gateway) is the
implementing architecture the Buddy lead decided under Matěj's mandate for
Buddy on the web; the Organization Admin may overrule it. The header said it
awaited a decision, and it named a draft file that exists only on one machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved on exact head ee20ebb. The start gate accepts only a ready, local Hermes identify advertising lazurio-oauth-cancel-contract-v1; an outdated gateway receives no start request (409 hermes-outdated), while status and sign-out without a held session remain available. Under the contract, poll 404 and DELETE "session not found" release the held sign-in; without it, they do not. I checked the fork contract at Lazurio/hermes-agent#17 d1cf093e: the sweep and runtime-lock release mark pending sessions under the worker save lock; a successor alone is no longer treated as proof. F63 drops the unsound 1223 s bound, records the rejected alternatives and the remaining single-profile/openai-codex limits, and clearly marks the fork release as a prerequisite. Local isolated checkout: 262/262 tests in the eight Buddy-named files passed under the host lock; exact-head Platform CI is green, and no unresolved review threads remain. This is review approval only, not authorization to merge, release, or unfreeze Platform main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants