From 2793888e49972ff44be9128d5295b6b184d923cc Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 11:50:29 +0300 Subject: [PATCH 01/21] feat(codex): complete the Codex sign-in from chat with /login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless Codex subscription setup was impossible to finish. Four causes, compounding: - Plain `codex login` starts a loopback OAuth server on localhost:1455 and prints an authorize URL whose redirect_uri points back at it. Opened on a laptop, the callback lands on the laptop's own localhost, so the CLI waits forever for a callback that never arrives — having printed no code at all. - `codex login --device-auth` does print a verification URL and a one-time code, but on stdout and then BLOCKS, polling. Anything that waits for the process to exit before reading shows nothing until the login is already over, which reads as the same hang. - That output is ANSI-colored even when stdout is a pipe, so the URL and the code arrive wrapped in escapes. - Startup refused to boot without a persisted login, while the only headless way to perform that login lives inside the running bot. A deadlock. core/codexauth drives `codex login --device-auth`, reads both streams incrementally, strips the escapes, and relays the link, the one-time code and the code's lifetime into the chat while the CLI keeps polling. It kills the whole process group on cancel, and separates a CLI too old for --device-auth, an expired code, and a clean exit that printed no code. /login (reserved command, both adapters, published in the Telegram menu) starts, re-shows, reports, cancels or forces the sign-in. A second /login while one is pending re-shows the SAME code instead of starting a competing flow that would invalidate it. airunner.BuildWithPendingLogin breaks the deadlock: a never-signed-in subscription deploy now BOOTS, blocks runs with a notice naming /login, and unblocks the moment auth.json lands — the verdict is re-read from disk on every message, so no restart is involved. Every other validation error stays fatal, so an API key in subscription mode or billing without acknowledgement still refuses to start. CODEX_REQUIRE_AUTH=false keeps its meaning and never blocks a run: an operator who disabled the check has taken responsibility for credentials this process cannot observe. CODEX_API_KEY is dropped from the sign-in environment in subscription mode — the one path that could silently turn a subscription deploy into API billing. Verified against the real Codex CLI (0.146.0; the pinned 0.143.0 also supports --device-auth, so no image change is needed): the parser recovers the URL, the code and the 15-minute lifetime off the live stream. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/telegram/.env.example | 7 + adapters/telegram/commands.go | 3 +- .../roles/claude_tg_bot/templates/env.j2 | 3 + adapters/vk/commands.go | 3 +- adapters/vk/receiver.go | 42 ++ adapters/vk/receiver_test.go | 69 +++ cmd/duck-vk/main.go | 14 +- cmd/flock-telegram/codex_login_test.go | 151 +++++++ cmd/flock-telegram/commands_test.go | 10 +- cmd/flock-telegram/main.go | 72 ++- core/chat/reserved.go | 3 +- core/chat/reserved_test.go | 4 +- core/codexauth/codexauth.go | 413 ++++++++++++++++++ core/codexauth/codexauth_test.go | 400 +++++++++++++++++ core/codexauth/login.go | 385 ++++++++++++++++ core/codexauth/login_test.go | 342 +++++++++++++++ docs/codex-integration-plan.md | 68 ++- internal/airunner/backend.go | 51 +++ internal/airunner/backend_test.go | 129 ++++++ 19 files changed, 2128 insertions(+), 41 deletions(-) create mode 100644 cmd/flock-telegram/codex_login_test.go create mode 100644 core/codexauth/codexauth.go create mode 100644 core/codexauth/codexauth_test.go create mode 100644 core/codexauth/login.go create mode 100644 core/codexauth/login_test.go diff --git a/adapters/telegram/.env.example b/adapters/telegram/.env.example index 92ea519..1f50a98 100644 --- a/adapters/telegram/.env.example +++ b/adapters/telegram/.env.example @@ -20,6 +20,13 @@ AI_BACKEND=claude # claude | codex | openai-compatible # CODEX_HOME/auth.json (persisted in the codex_home volume) or CODEX_ACCESS_TOKEN. # Keep CODEX_API_KEY empty in subscription mode: startup fails if it is set, so a # subscription deploy cannot accidentally switch to usage-based API billing. +# +# First-time sign-in needs no shell on the host: the bot STARTS unauthorized and +# an allow-listed user sends /login in the chat. The bot replies with a +# verification link and a one-time code (this is `codex login --device-auth`; +# plain `codex login` would open a loopback server on localhost:1455 that nothing +# outside the container can reach, which is why it appears to hang). Open the +# link, enter the code, and the bot confirms — runs are blocked until then. CODEX_AUTH_MODE=subscription # subscription | billing CODEX_BIN=codex CODEX_HOME=/home/claude/.codex diff --git a/adapters/telegram/commands.go b/adapters/telegram/commands.go index f0d594a..5707e6c 100644 --- a/adapters/telegram/commands.go +++ b/adapters/telegram/commands.go @@ -92,7 +92,8 @@ const HelpText = "Flock Telegram assistant — available commands:\n\n" + "/stop — stop the run currently in progress\n" + "/schedule — manage scheduled jobs (when enabled)\n" + "/goal — arm a goal an independent evaluator re-checks after every run " + - "(/goal off to disarm)\n\n" + + "(/goal off to disarm)\n" + + "/login — authorize the AI provider (Codex browser sign-in; /login status, /login cancel)\n\n" + "Send any other message to run it through the assistant." // WelcomeText is the static usage message replied to an allowed user who sends diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 b/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 index 4f06267..5c2c237 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 +++ b/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 @@ -29,6 +29,9 @@ CLAUDE_MAX_COST_PER_REQUEST={{ claude_max_cost_per_request }} # === CODEX AUTH === # subscription: use persisted {{ codex_home }}/auth.json or CODEX_ACCESS_TOKEN. +# With neither, the bot starts unauthorized and an allow-listed user completes +# the one-time sign-in from the chat with /login (device code: the bot posts a +# link + a one-time code). No shell on the host is needed. # billing: requires CODEX_API_KEY and CODEX_BILLING_ACK=true. CODEX_BIN={{ codex_bin }} CODEX_MODEL={{ codex_model }} diff --git a/adapters/vk/commands.go b/adapters/vk/commands.go index 5c01dc8..4bb52fd 100644 --- a/adapters/vk/commands.go +++ b/adapters/vk/commands.go @@ -10,7 +10,8 @@ const HelpText = "Flock VK assistant — available commands:\n\n" + "/stop — stop the run currently in progress\n" + "/schedule — manage scheduled jobs (when enabled)\n" + "/goal — arm a goal an independent evaluator re-checks after every run " + - "(/goal off to disarm)\n\n" + + "(/goal off to disarm)\n" + + "/login — authorize the AI provider (Codex browser sign-in; /login status, /login cancel)\n\n" + "Send any other message to run it through the assistant." // goalUsageText is the /goal usage reply, mirroring the Telegram adapter. diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index ac95e4a..937ca60 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -12,10 +12,16 @@ import ( "github.com/duckbugio/flock/core/agent" "github.com/duckbugio/flock/core/chat" + "github.com/duckbugio/flock/core/codexauth" "github.com/duckbugio/flock/core/goal" "github.com/duckbugio/flock/core/schedule" ) +// loginNotifyTimeout bounds one background Codex-login notice delivery. The +// device flow reports minutes after the /login update's own context is gone, so +// each notice is sent on a fresh, bounded context of its own. +const loginNotifyTimeout = 30 * time.Second + // scheduleDisabledText is the reply when /schedule is used but the scheduler is // turned off (the default). It mirrors the Telegram adapter's notice. const scheduleDisabledText = "Scheduler is disabled. Set ENABLE_SCHEDULER=true to enable it." @@ -115,6 +121,7 @@ type Receiver struct { notices NoticeSender eventAck eventAckFunc sched *schedule.Manager + auth *codexauth.Manager logger *slog.Logger } @@ -134,6 +141,10 @@ type ReceiverConfig struct { // Scheduler serves the /schedule command. Nil when ENABLE_SCHEDULER is off, in // which case /schedule replies with the disabled notice. Scheduler *schedule.Manager + // CodexAuth serves /login and blocks runs while a Codex subscription deploy has + // no completed sign-in. A nil manager never blocks and reports /login as + // unnecessary. + CodexAuth *codexauth.Manager Logger *slog.Logger } @@ -160,6 +171,7 @@ func NewReceiver(cfg ReceiverConfig) *Receiver { notices: cfg.Notices, eventAck: cfg.EventAck, sched: cfg.Scheduler, + auth: cfg.CodexAuth, logger: log, } } @@ -328,6 +340,16 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { return } + // Codex with no completed sign-in cannot run anything. Say so once, after the + // gate (so an unaddressed community message stays silent) and before any paid + // work (transcription, downloads) or a run that would only fail inside the CLI. + // /login is dispatched above and stays reachable while this gate is closed. + if notice, blocked := r.auth.BlockedNotice(); blocked { + r.logger.Debug("vk: codex unauthorized — blocking run", "peer_id", peerID) + r.notify(ctx, peerID, notice) + return + } + if r.guards != nil { if allow, reason := r.guards(msg.FromID); !allow { r.logger.Debug("vk: guardrail denied message", "peer_id", peerID, "user_id", msg.FromID, "reason", reason) @@ -395,7 +417,27 @@ func (r *Receiver) dispatchReserved(ctx context.Context, name string, msg messag r.dispatchSchedule(ctx, msg) case "goal": r.dispatchGoal(ctx, msg) + case "login": + r.dispatchLogin(ctx, msg) + } +} + +// dispatchLogin serves /login: start, report, or cancel the Codex device-code +// sign-in. It stays available while the deployment is unauthorized — it is the +// only way out of that state. +// +// The sign-in outlives this call: Dispatch replies immediately and keeps working +// in the background, delivering the link, the one-time code, and the verdict +// through notify. That callback builds its OWN context, because this update's is +// long gone by the time a user finishes in a browser. +func (r *Receiver) dispatchLogin(ctx context.Context, msg messageObject) { + peerID := msg.PeerID + notify := func(text string) { + sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginNotifyTimeout) + defer cancel() + r.notify(sendCtx, peerID, text) } + r.notify(ctx, peerID, r.auth.Dispatch(ctx, commandArgs(msg.Text), notify)) } // dispatchGoal serves /goal: arm, show, or disarm the calling chat's goal via diff --git a/adapters/vk/receiver_test.go b/adapters/vk/receiver_test.go index c59421f..6197677 100644 --- a/adapters/vk/receiver_test.go +++ b/adapters/vk/receiver_test.go @@ -12,6 +12,7 @@ import ( "github.com/duckbugio/flock/core/agent" "github.com/duckbugio/flock/core/chat" + "github.com/duckbugio/flock/core/codexauth" "github.com/duckbugio/flock/core/goal" "github.com/duckbugio/flock/core/schedule" ) @@ -623,3 +624,71 @@ func (f *fakeLongPoller) poll(_ context.Context, _ longPollServer) (longPollResp func isContextErr(err error) bool { return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) } + +// unauthorizedCodexReceiver wires a receiver whose Codex backend has never been +// signed in (an empty CODEX_HOME, so no auth.json). +func unauthorizedCodexReceiver(t *testing.T, svc Service, notices NoticeSender) *Receiver { + t.Helper() + r := newTestReceiver(svc, notices, false, nil) + r.auth = codexauth.NewManager(codexauth.Config{ + Backend: codexauth.BackendCodex, + AuthMode: codexauth.AuthSubscription, + RequireAuth: true, + Home: t.TempDir(), + }) + return r +} + +// TestReceiverBlocksRunsWhileCodexUnauthorized: a Codex deploy with no completed +// sign-in must answer with the actionable notice instead of starting a run that +// could only fail inside the CLI. +func TestReceiverBlocksRunsWhileCodexUnauthorized(t *testing.T) { + svc := &fakeService{} + notices := &fakeNotice{} + r := unauthorizedCodexReceiver(t, svc, notices) + + r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 200, Text: "build it"})) + + if len(svc.handleCalls) != 0 { + t.Errorf("an unauthorized Codex deploy started %d runs, want 0", len(svc.handleCalls)) + } + if len(notices.texts) != 1 || !strings.Contains(notices.texts[0], "/login") { + t.Errorf("notices = %v, want one notice pointing at /login", notices.texts) + } +} + +// TestReceiverLoginCommandStaysReachableWhileUnauthorized is the escape hatch: +// the very command that fixes the unauthorized state must not be blocked by it. +func TestReceiverLoginCommandStaysReachableWhileUnauthorized(t *testing.T) { + svc := &fakeService{} + notices := &fakeNotice{} + r := unauthorizedCodexReceiver(t, svc, notices) + + r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 200, Text: "/login status"})) + + if len(svc.handleCalls) != 0 { + t.Errorf("/login should not start a run, got %d Handle calls", len(svc.handleCalls)) + } + if len(notices.texts) != 1 || !strings.Contains(notices.texts[0], "NOT authorized") { + t.Errorf("notices = %v, want the login status reply", notices.texts) + } +} + +// TestReceiverLoginWithoutManagerIsInert: a deployment wired without a Codex auth +// manager (the Claude default) answers /login instead of panicking, and never +// blocks a run. +func TestReceiverLoginWithoutManagerIsInert(t *testing.T) { + svc := &fakeService{} + notices := &fakeNotice{} + r := newTestReceiver(svc, notices, false, nil) + + r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 200, Text: "/login"})) + if len(notices.texts) != 1 || notices.texts[0] != codexauth.NoLoginNeededText { + t.Errorf("notices = %v, want the no-login-needed reply", notices.texts) + } + + r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 200, Text: "build it"})) + if len(svc.handleCalls) != 1 { + t.Errorf("Handle calls = %d, want 1 (no manager must never block)", len(svc.handleCalls)) + } +} diff --git a/cmd/duck-vk/main.go b/cmd/duck-vk/main.go index 28fa443..7ab5ca3 100644 --- a/cmd/duck-vk/main.go +++ b/cmd/duck-vk/main.go @@ -27,6 +27,7 @@ import ( "github.com/duckbugio/flock/adapters/vk" "github.com/duckbugio/flock/core/chat" "github.com/duckbugio/flock/core/claude" + "github.com/duckbugio/flock/core/codexauth" "github.com/duckbugio/flock/core/cost" "github.com/duckbugio/flock/core/dispatch" "github.com/duckbugio/flock/core/ghstar" @@ -78,16 +79,26 @@ func run() int { })) slog.SetDefault(logger) - runner, opts, provider, err := airunner.Build(cfg) + // A Codex subscription deploy that has never been signed in starts anyway, in a + // "needs login" state: /login is the only headless way to complete that sign-in, + // so refusing to boot would make it unreachable. Runs stay blocked (with an + // actionable notice) until the login lands. + runner, opts, provider, pendingLogin, err := airunner.BuildWithPendingLogin(cfg) if err != nil { logger.Error("invalid ai provider config", "provider", cfg.AIBackend, "error", err) return 1 } + auth := codexauth.NewManager(airunner.CodexAuthConfig(cfg, provider)) + if pendingLogin { + logger.Warn("codex is not authorized yet; runs are blocked until /login completes", + "codex_home", cfg.CodexHome) + } if provider.Name == config.AIBackendCodex { logger.Info("codex backend enabled", "provider", provider.Name, "display_name", provider.DisplayName, "auth_mode", cfg.CodexAuthModeName(), + "authorized", auth.Authorized(), "sandbox", cfg.CodexSandbox, "approval_policy", cfg.CodexApprovalPolicy, "codex_home", cfg.CodexHome, @@ -266,6 +277,7 @@ func run() int { Notices: vk.NewNoticeSender(api, time.Now().UnixNano(), logger), EventAck: api.SendMessageEventAnswer, Scheduler: mgr, + CodexAuth: auth, Logger: logger, }) diff --git a/cmd/flock-telegram/codex_login_test.go b/cmd/flock-telegram/codex_login_test.go new file mode 100644 index 0000000..f86d88f --- /dev/null +++ b/cmd/flock-telegram/codex_login_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/duckbugio/flock/core/chat" + "github.com/duckbugio/flock/core/codexauth" + "github.com/duckbugio/flock/internal/config" +) + +// unauthorizedCodexAuth returns a manager for a Codex deploy that has never been +// signed in: subscription mode over an empty CODEX_HOME (no auth.json). It also +// returns that home so a test can "complete" the login by planting the file. +func unauthorizedCodexAuth(t *testing.T) (*codexauth.Manager, string) { + t.Helper() + home := t.TempDir() + return codexauth.NewManager(codexauth.Config{ + Backend: codexauth.BackendCodex, + AuthMode: codexauth.AuthSubscription, + RequireAuth: true, + Home: home, + }), home +} + +// textMessage builds a plain private-chat text message from userID in chatID. +func textMessage(userID, chatID int64, text string) *models.Message { + return &models.Message{ + ID: 1, + Text: text, + From: &models.User{ID: userID}, + Chat: models.Chat{ID: chatID, Type: models.ChatTypePrivate}, + } +} + +// quietBot builds a bot that answers every API call locally, so a handler that +// posts a reply is a no-op with no network. +func quietBot(t *testing.T) *bot.Bot { + t.Helper() + b, err := bot.New("123456:test-token", + bot.WithSkipGetMe(), + bot.WithNotAsyncHandlers(), + bot.WithHTTPClient(time.Minute, stubHTTPClient{}), + ) + if err != nil { + t.Fatalf("build test bot: %v", err) + } + return b +} + +// TestHandleMessageBlockedWhileCodexUnauthorized: with no completed Codex +// sign-in, a normal message must NOT reach the run Service — it would only fail +// deep inside the CLI, after paying for any transcription or download on the way. +func TestHandleMessageBlockedWhileCodexUnauthorized(t *testing.T) { + auth, _ := unauthorizedCodexAuth(t) + svc := &recordingSubmitter{} + cfg := config.Config{AllowedUsers: []int64{42}} + deps := messageDeps{cfg: cfg, service: svc, auth: auth} + + handleMessage(context.Background(), deps, quietBot(t), textMessage(42, 200, "build it"), false) + + if got := svc.seen(); len(got) != 0 { + t.Errorf("submitted %v while Codex was unauthorized, want nothing", got) + } +} + +// TestHandleMessageUnblocksAfterLogin: once the login has persisted auth.json the +// standard flow continues — in the SAME process, with no restart. That is the +// whole point of re-checking the file rather than caching a startup verdict. +func TestHandleMessageUnblocksAfterLogin(t *testing.T) { + auth, home := unauthorizedCodexAuth(t) + svc := &recordingSubmitter{} + cfg := config.Config{AllowedUsers: []int64{42}} + deps := messageDeps{cfg: cfg, service: svc, auth: auth} + b := quietBot(t) + + handleMessage(context.Background(), deps, b, textMessage(42, 200, "build it"), false) + if got := svc.seen(); len(got) != 0 { + t.Fatalf("submitted %v before the login, want nothing", got) + } + + if err := os.WriteFile(filepath.Join(home, "auth.json"), []byte("{}"), 0o600); err != nil { + t.Fatalf("write auth.json: %v", err) + } + + handleMessage(context.Background(), deps, b, textMessage(42, 200, "build it"), false) + if got := svc.seen(); len(got) != 1 || got[0] != "build it" { + t.Errorf("submitted %v after the login, want ['build it']", got) + } +} + +// TestHandleMessageWithoutCodexAuthManager: the Claude default wires no manager, +// which must never block a message. +func TestHandleMessageWithoutCodexAuthManager(t *testing.T) { + svc := &recordingSubmitter{} + cfg := config.Config{AllowedUsers: []int64{42}} + deps := messageDeps{cfg: cfg, service: svc} + + handleMessage(context.Background(), deps, quietBot(t), textMessage(42, 200, "build it"), false) + + if got := svc.seen(); len(got) != 1 { + t.Errorf("submitted %v with no auth manager, want the message through", got) + } +} + +// TestLoginCommandIsRoutedToItsHandler: /login is reserved, so it must be served +// by the bot and never forwarded to the model as a prompt — and it must stay +// reachable while the unauthorized gate is closed, since it is the way out. +func TestLoginCommandIsRoutedToItsHandler(t *testing.T) { + auth, _ := unauthorizedCodexAuth(t) + svc := &recordingSubmitter{} + cfg := config.Config{AllowedUsers: []int64{42}} + + defaultHandler := func(ctx context.Context, b *bot.Bot, update *models.Update) { + if msg := update.Message; msg != nil { + handleMessage(ctx, messageDeps{cfg: cfg, service: svc, auth: auth}, b, msg, false) + } + } + b, err := bot.New("123456:test-token", + bot.WithSkipGetMe(), + bot.WithNotAsyncHandlers(), + bot.WithHTTPClient(time.Minute, stubHTTPClient{}), + bot.WithDefaultHandler(defaultHandler), + ) + if err != nil { + t.Fatalf("build test bot: %v", err) + } + for name, h := range reservedHandlers(cfg, nil, nil, auth) { + b.RegisterHandlerMatchFunc(commandMatch(name), h) + } + + b.ProcessUpdate(context.Background(), privateCommandUpdate(42, 200, "/login status", len("/login"))) + + if got := svc.seen(); len(got) != 0 { + t.Errorf("/login leaked to the model as %v, want it handled by the bot", got) + } +} + +// TestLoginIsAReservedCommand pins /login into the canonical set, which is what +// makes both adapters intercept it and Telegram publish it in the command menu. +func TestLoginIsAReservedCommand(t *testing.T) { + if !chat.IsReservedCommand("login") { + t.Fatal("login is not a reserved command; it would be forwarded to the model") + } +} diff --git a/cmd/flock-telegram/commands_test.go b/cmd/flock-telegram/commands_test.go index 59a7f9c..68f9e36 100644 --- a/cmd/flock-telegram/commands_test.go +++ b/cmd/flock-telegram/commands_test.go @@ -143,9 +143,9 @@ func TestStartWelcomeText(t *testing.T) { // Claude as free text. Keying both off the same source and asserting equality makes // that impossible. func TestReservedHandlersMatchCanonicalSet(t *testing.T) { - // A nil *chat.Service and nil *schedule.Manager are fine: we only inspect the - // map's key set, never invoke a handler closure. - handlers := reservedHandlers(config.Config{}, nil, nil) + // A nil *chat.Service, *schedule.Manager and *codexauth.Manager are fine: we + // only inspect the map's key set, never invoke a handler closure. + handlers := reservedHandlers(config.Config{}, nil, nil, nil) if len(handlers) != len(chat.ReservedCommands) { t.Fatalf("reservedHandlers has %d entries, want %d (chat.ReservedCommands)", @@ -258,7 +258,7 @@ func newCommandTestBot(t *testing.T, cfg config.Config, svc messageSubmitter, re // Register the reserved-command handlers from the SAME canonical source main uses, // so the routing (reserved handler vs default) is identical to production. The // scheduler is nil here (the pass-through tests do not exercise /schedule). - for name, h := range reservedHandlers(cfg, reservedSvc, nil) { + for name, h := range reservedHandlers(cfg, reservedSvc, nil, nil) { b.RegisterHandlerMatchFunc(commandMatch(name), h) } return b @@ -354,7 +354,7 @@ func TestScheduleCommandEnabledReachesDispatch(t *testing.T) { if err != nil { t.Fatalf("build test bot: %v", err) } - for name, h := range reservedHandlers(cfg, nil, mgr) { + for name, h := range reservedHandlers(cfg, nil, mgr, nil) { b.RegisterHandlerMatchFunc(commandMatch(name), h) } diff --git a/cmd/flock-telegram/main.go b/cmd/flock-telegram/main.go index f762437..e8e991a 100644 --- a/cmd/flock-telegram/main.go +++ b/cmd/flock-telegram/main.go @@ -28,6 +28,7 @@ import ( "github.com/duckbugio/flock/core/agent" "github.com/duckbugio/flock/core/chat" "github.com/duckbugio/flock/core/claude" + "github.com/duckbugio/flock/core/codexauth" "github.com/duckbugio/flock/core/cost" "github.com/duckbugio/flock/core/dispatch" "github.com/duckbugio/flock/core/ghstar" @@ -76,16 +77,26 @@ func run() int { })) slog.SetDefault(logger) - runner, opts, provider, err := airunner.Build(cfg) + // A Codex subscription deploy that has never been signed in starts anyway, in a + // "needs login" state: the /login command is the ONLY way to complete that + // sign-in headlessly, so refusing to boot would make it unreachable. Runs stay + // blocked (with an actionable notice) until the login lands. + runner, opts, provider, pendingLogin, err := airunner.BuildWithPendingLogin(cfg) if err != nil { logger.Error("invalid ai provider config", "provider", cfg.AIBackend, "error", err) return 1 } + auth := codexauth.NewManager(airunner.CodexAuthConfig(cfg, provider)) + if pendingLogin { + logger.Warn("codex is not authorized yet; runs are blocked until /login completes", + "codex_home", cfg.CodexHome) + } if provider.Name == config.AIBackendCodex { logger.Info("codex backend enabled", "provider", provider.Name, "display_name", provider.DisplayName, "auth_mode", cfg.CodexAuthModeName(), + "authorized", auth.Authorized(), "sandbox", cfg.CodexSandbox, "approval_policy", cfg.CodexApprovalPolicy, "codex_home", cfg.CodexHome, @@ -221,7 +232,7 @@ func run() int { guards := chat.GuardConfig{CostCapUSD: cfg.EffectiveCostCapUSD()} opts2 := []bot.Option{ - bot.WithDefaultHandler(textHandler(cfg, &svc, &vt, &up, limiter, costs, guards)), + bot.WithDefaultHandler(textHandler(cfg, &svc, &vt, &up, limiter, costs, guards, auth)), } b, err := bot.New(cfg.TelegramBotToken, opts2...) @@ -348,7 +359,7 @@ func run() int { // explicit address). The set of names handled here is asserted, by test, to equal // the canonical chat.ReservedCommands so a new reserved command can never be // published in the menu yet leak to the model for lack of a handler. - handlers := reservedHandlers(cfg, svc, mgr) + handlers := reservedHandlers(cfg, svc, mgr, auth) for name, h := range handlers { b.RegisterHandlerMatchFunc(commandMatch(name), h) } @@ -426,6 +437,7 @@ func textHandler( limiter *ratelimit.Limiter, costs *cost.Store, guards chat.GuardConfig, + auth *codexauth.Manager, ) bot.HandlerFunc { return func(ctx context.Context, b *bot.Bot, update *models.Update) { service := *svc @@ -437,12 +449,15 @@ func textHandler( // Voice and media uploads apply only to new messages, never edits, so // neither the transcriber nor the uploader is threaded here. if edited := update.EditedMessage; edited != nil { - deps := messageDeps{cfg: cfg, service: service, limiter: limiter, costs: costs, guards: guards} + deps := messageDeps{cfg: cfg, service: service, limiter: limiter, costs: costs, guards: guards, auth: auth} handleMessage(ctx, deps, b, edited, true) return } if msg := update.Message; msg != nil { - deps := messageDeps{cfg: cfg, service: service, vt: *vt, up: *up, limiter: limiter, costs: costs, guards: guards} + deps := messageDeps{ + cfg: cfg, service: service, vt: *vt, up: *up, + limiter: limiter, costs: costs, guards: guards, auth: auth, + } handleMessage(ctx, deps, b, msg, false) } } @@ -478,6 +493,9 @@ type messageDeps struct { limiter *ratelimit.Limiter costs *cost.Store guards chat.GuardConfig + // auth blocks runs while the Codex backend has no completed sign-in. Nil (and + // a nil *Manager) means "never blocks". + auth *codexauth.Manager } // handleMessage applies the allow-list and the group mention-gate to one message @@ -534,6 +552,17 @@ func handleMessage(ctx context.Context, deps messageDeps, b *bot.Bot, msg *model return } + // Codex with no completed sign-in cannot run anything. Say so once, here — after + // the gate (so an unaddressed group message stays silent) but before any paid + // work (transcription, downloads) or a run that would only fail deep inside the + // CLI. /login is a reserved command routed elsewhere, so it stays reachable + // while this gate is closed. + if notice, blocked := deps.auth.BlockedNotice(); blocked { + slog.Debug("codex unauthorized — blocking run", "chat_id", msg.Chat.ID) + sendCommandReply(ctx, b, msg.Chat.ID, notice) + return + } + // Guardrails (plan §9b): once the message is accepted for handling but BEFORE // any paid work (voice transcription or the Claude run), apply the per-user // rate limit and cumulative cost cap. A denied message spends no transcription @@ -969,7 +998,9 @@ func reservedBotCommands() []models.BotCommand { // asserts this map's key set equals chat.ReservedCommands. Adding a reserved command // is therefore a two-line change here (plus the canonical entry); forgetting the // handler fails the test rather than silently leaking the command to Claude. -func reservedHandlers(cfg config.Config, svc *chat.Service, sched *schedule.Manager) map[string]bot.HandlerFunc { +func reservedHandlers( + cfg config.Config, svc *chat.Service, sched *schedule.Manager, auth *codexauth.Manager, +) map[string]bot.HandlerFunc { return map[string]bot.HandlerFunc{ "start": startHandler(cfg), "help": helpHandler(cfg), @@ -977,9 +1008,38 @@ func reservedHandlers(cfg config.Config, svc *chat.Service, sched *schedule.Mana "stop": stopCommandHandler(cfg, svc), "schedule": scheduleHandler(cfg, sched), "goal": goalHandler(cfg, svc), + "login": loginHandler(cfg, auth), } } +// loginHandler serves /login: it starts (or reports, or cancels) the Codex +// device-code sign-in. The command is deliberately available even while the +// deployment is unauthorized — it is the only way out of that state — and, like +// every reserved handler, is allow-list gated and never starts a run. +// +// The device flow outlives this handler: Dispatch returns an immediate reply and +// keeps working in the background, delivering the link, the one-time code, and +// the final verdict through the notify callback. That callback therefore builds +// its OWN context: the update's ctx is cancelled as soon as the handler returns, +// minutes before a user finishes signing in, and sending on it would fail. +func loginHandler(cfg config.Config, auth *codexauth.Manager) bot.HandlerFunc { + return func(ctx context.Context, b *bot.Bot, update *models.Update) { + chatID, ok := commandSender(cfg, update.Message) + if !ok { + return + } + notify := func(text string) { + sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginNotifyTimeout) + defer cancel() + sendCommandReply(sendCtx, b, chatID, text) + } + sendCommandReply(ctx, b, chatID, auth.Dispatch(ctx, commandArgs(update.Message.Text), notify)) + } +} + +// loginNotifyTimeout bounds one background login notice delivery. +const loginNotifyTimeout = 30 * time.Second + // commandMatch routes a message whose leading bot command is name, tolerating // the @botname suffix Telegram adds in groups (/new@duck_bot). It only routes; // the allow-list gate lives in each handler via commandSender. diff --git a/core/chat/reserved.go b/core/chat/reserved.go index 40070f0..6ed0de1 100644 --- a/core/chat/reserved.go +++ b/core/chat/reserved.go @@ -17,7 +17,7 @@ type ReservedCommand struct { // /security-review) is forwarded verbatim so the provider's own slash-command and // skill system runs it. Both adapters (Telegram, VK) decide what to intercept // from this list, and Telegram builds its command menu from it. The order here is -// the order the menu presents: start, help, new, stop, schedule, goal. +// the order the menu presents: start, help, new, stop, schedule, goal, login. var ReservedCommands = []ReservedCommand{ {Name: "start", Description: "Show a short welcome and usage"}, {Name: "help", Description: "List the bot's own commands"}, @@ -25,6 +25,7 @@ var ReservedCommands = []ReservedCommand{ {Name: "stop", Description: "Stop the run currently in progress"}, {Name: "schedule", Description: "Manage scheduled jobs"}, {Name: "goal", Description: "Arm a goal an independent evaluator re-checks after every run"}, + {Name: "login", Description: "Authorize the AI provider (Codex browser sign-in)"}, } // IsReservedCommand reports whether name is one of the bot's reserved commands. diff --git a/core/chat/reserved_test.go b/core/chat/reserved_test.go index 82e639a..8702ea6 100644 --- a/core/chat/reserved_test.go +++ b/core/chat/reserved_test.go @@ -46,9 +46,9 @@ func TestIsReservedCommand(t *testing.T) { // TestReservedCommandsShape guards the menu source: names are unique, lowercase, // non-empty, every command has a description, and the order is the documented -// start, help, new, stop, schedule, goal. +// start, help, new, stop, schedule, goal, login. func TestReservedCommandsShape(t *testing.T) { - wantOrder := []string{"start", "help", "new", "stop", "schedule", "goal"} + wantOrder := []string{"start", "help", "new", "stop", "schedule", "goal", "login"} if len(chat.ReservedCommands) != len(wantOrder) { t.Fatalf("ReservedCommands has %d entries, want %d", len(chat.ReservedCommands), len(wantOrder)) } diff --git a/core/codexauth/codexauth.go b/core/codexauth/codexauth.go new file mode 100644 index 0000000..81a360d --- /dev/null +++ b/core/codexauth/codexauth.go @@ -0,0 +1,413 @@ +package codexauth + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// Codex auth modes, mirroring internal/config's CODEX_AUTH_MODE values. Only the +// subscription mode has an interactive login: billing authenticates with an API +// key from the environment and has nothing for a user to confirm in a browser. +const ( + AuthSubscription = "subscription" + AuthBilling = "billing" +) + +// BackendCodex is the AI_BACKEND value this package applies to. Any other +// backend (Claude, an OpenAI-compatible endpoint) authenticates from env alone, +// so /login is a no-op there. +const BackendCodex = "codex" + +// Config describes the deployment's Codex auth setup. It is built once at +// startup (see airunner.CodexAuthConfig) and read for every /login. +type Config struct { + // Backend is the resolved AI_BACKEND name. + Backend string + // AuthMode is the resolved CODEX_AUTH_MODE ("subscription" or "billing"). + AuthMode string + // Bin is the Codex CLI path (CODEX_BIN); empty means "codex" on PATH. + Bin string + // Home is CODEX_HOME — the directory holding the persisted auth.json that a + // successful login writes and every later run reads. + Home string + // Env is the child environment for the Codex CLI, already stripped of + // CODEX_API_KEY in subscription mode. Empty inherits the parent's. + Env []string + // HasAccessToken records that CODEX_ACCESS_TOKEN is configured, which + // authorizes Codex without any interactive login. + HasAccessToken bool + // RequireAuth mirrors CODEX_REQUIRE_AUTH. When false the operator has + // explicitly declared that Codex is authorized by means this process cannot + // see (a home mounted later, credentials in config.toml), so runs are never + // blocked — /login stays available, it just is not demanded. + RequireAuth bool + // Timeout bounds one login attempt. Zero means DeviceCodeTTL plus a minute of + // slack, so an abandoned login cannot outlive the code it waits on. + Timeout time.Duration + Logger *slog.Logger +} + +// bin returns the Codex CLI to execute. +func (c Config) bin() string { + if b := strings.TrimSpace(c.Bin); b != "" { + return b + } + return "codex" +} + +// childEnv returns the environment for the login child. +// +// CODEX_HOME is forced to the configured value so the login writes auth.json +// exactly where the runner later looks for it — a login into a different home is +// the subtlest way for "logged in" and "still unauthorized" to be true at once. +// +// CODEX_API_KEY is dropped in subscription mode. Env normally arrives already +// stripped (airunner.CodexEnv), but this is the one place a stray inherited key +// could turn a subscription sign-in into API-billing auth, so the invariant is +// enforced here too rather than assumed. +func (c Config) childEnv() []string { + env := c.Env + if len(env) == 0 { + env = os.Environ() + } + dropAPIKey := strings.EqualFold(strings.TrimSpace(c.AuthMode), AuthSubscription) + home := strings.TrimSpace(c.Home) + + out := make([]string, 0, len(env)+1) + for _, kv := range env { + if home != "" && strings.HasPrefix(kv, "CODEX_HOME=") { + continue + } + if dropAPIKey && strings.HasPrefix(kv, "CODEX_API_KEY=") { + continue + } + out = append(out, kv) + } + if home == "" { + return out + } + return append(out, "CODEX_HOME="+home) +} + +// authFile is the persisted login file a successful device login writes. +func (c Config) authFile() string { + home := strings.TrimSpace(c.Home) + if home == "" { + return "" + } + return filepath.Join(home, "auth.json") +} + +// timeout bounds a single login attempt. +func (c Config) timeout() time.Duration { + if c.Timeout > 0 { + return c.Timeout + } + return DeviceCodeTTL + time.Minute +} + +// logger never returns nil. +func (c Config) logger() *slog.Logger { + if c.Logger != nil { + return c.Logger + } + return slog.Default() +} + +// Manager owns the at-most-one in-flight device login for the process and +// answers the /login command. Codex auth is process-wide state (a single +// auth.json under CODEX_HOME), so the login is deliberately NOT per chat: a +// second /login while one is pending re-shows the SAME code rather than starting +// a competing flow that would invalidate it. +type Manager struct { + cfg Config + + mu sync.Mutex + running bool + cancel context.CancelFunc + last Prompt + hasLast bool +} + +// NewManager returns a Manager for cfg. +func NewManager(cfg Config) *Manager { return &Manager{cfg: cfg} } + +// Applicable reports whether an interactive login means anything on this +// deployment: only the Codex backend in subscription mode has one. +func (m *Manager) Applicable() bool { + if m == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(m.cfg.Backend), BackendCodex) && + strings.EqualFold(strings.TrimSpace(m.cfg.AuthMode), AuthSubscription) +} + +// Authorized reports whether Codex can run right now: a configured access token, +// or a persisted auth.json under CODEX_HOME. It re-checks the file on every call +// rather than caching a startup verdict, so the very first run after a +// successful /login is allowed through without a restart. +// +// CODEX_REQUIRE_AUTH=false opts out of the check entirely, exactly as it does at +// startup: an operator who disabled it has taken responsibility for credentials +// this process cannot observe, and must not have their runs blocked. +func (m *Manager) Authorized() bool { + if !m.Applicable() || !m.cfg.RequireAuth { + return true + } + return m.credentialsPresent() +} + +// credentialsPresent reports whether Codex has something to authenticate WITH, +// independent of whether the deployment demands it. It is the honest answer +// /login reasons about; Authorized is the policy answer runs are gated on. +func (m *Manager) credentialsPresent() bool { + if m == nil { + return false + } + if m.cfg.HasAccessToken { + return true + } + f := m.cfg.authFile() + if f == "" { + return false + } + _, err := os.Stat(f) + return err == nil +} + +// NeedsLogin is the inverse of Authorized, for callers that read better that way. +func (m *Manager) NeedsLogin() bool { return !m.Authorized() } + +// BlockedNotice returns the reply to send instead of starting a run while Codex +// is unauthorized, and whether the run must be blocked at all. Blocking here — +// rather than letting the run fail deep inside the CLI — is what makes the +// unauthorized state recoverable: the user is told the one command that fixes it. +func (m *Manager) BlockedNotice() (string, bool) { + if m == nil || m.Authorized() { + return "", false + } + return "Codex is not authorized yet, so I can't run anything.\n\n" + + "Send /login and I'll walk you through the one-time browser sign-in.", true +} + +// NoLoginNeededText is the reply when this deployment has no interactive login +// at all — including when no Manager was wired up. +const NoLoginNeededText = "No interactive login is needed on this deployment: " + + "the AI provider authenticates from its configured credentials." + +// StatusText describes the current auth state without ever revealing a secret. +func (m *Manager) StatusText() string { + if !m.Applicable() { + return m.notApplicableText() + } + m.mu.Lock() + running, last, hasLast := m.running, m.last, m.hasLast + m.mu.Unlock() + + switch { + case running && hasLast: + return "Codex device login is in progress — finish it in the browser:\n\n" + promptText(last) + case running: + return "Codex device login is starting; the link and code arrive in a moment." + case m.cfg.HasAccessToken: + return "Codex is authorized with a configured access token. No browser login needed." + case m.credentialsPresent(): + return "Codex is authorized (persisted login in " + m.cfg.Home + ")." + case !m.cfg.RequireAuth: + return "No persisted Codex login found, but CODEX_REQUIRE_AUTH=false, so runs are not blocked. " + + "Send /login if Codex reports it is unauthenticated." + default: + return "Codex is NOT authorized. Send /login to sign in with your ChatGPT account." + } +} + +// notApplicableText explains why /login does nothing on this deployment. It is +// nil-safe: an adapter wired without a Manager still gets a sensible reply +// instead of a panic. +func (m *Manager) notApplicableText() string { + if m == nil { + return NoLoginNeededText + } + backend := strings.TrimSpace(m.cfg.Backend) + if backend == "" { + backend = "the configured provider" + } + if strings.EqualFold(backend, BackendCodex) { + return "Codex runs in API-billing mode here, which authenticates from CODEX_API_KEY. " + + "There is no browser login to complete." + } + return "No interactive login is needed: this deployment runs " + backend + + ", which authenticates from its configured credentials." +} + +// LoginUsage is the /login help. +const LoginUsage = "Usage:\n" + + "/login — start the Codex browser sign-in (or re-show the pending code)\n" + + "/login status — show the current authorization state\n" + + "/login cancel — abort a pending sign-in\n" + + "/login force — sign in again even if already authorized" + +// Dispatch serves the /login command and returns the IMMEDIATE reply. +// +// The device flow outlives the command: after the immediate reply, the pending +// login keeps running in the background and reports the verification link, the +// one-time code, and the final outcome through notify. notify may therefore be +// called minutes later and from another goroutine — the caller must make it safe +// to use then (its own context, not the update's). +// +// base only seeds the login's values; its cancellation does NOT abort the login +// (the update's context is dead long before a user finishes in a browser). +// /login cancel, the timeout, and process shutdown are the ways it ends. +func (m *Manager) Dispatch(base context.Context, args string, notify func(string)) string { + if !m.Applicable() { + return m.notApplicableText() + } + switch strings.ToLower(strings.TrimSpace(args)) { + case "": + return m.start(base, false, notify) + case "force", "again", "relogin": + return m.start(base, true, notify) + case "status": + return m.StatusText() + case "cancel", "abort", "stop": + if m.Cancel() { + return "Cancelled the pending Codex login." + } + return "No Codex login is pending." + case "help": + return LoginUsage + default: + return "Unknown /login argument.\n\n" + LoginUsage + } +} + +// start launches a device login unless one is already pending (in which case the +// pending code is re-shown, never replaced) or Codex is already authorized and +// force was not asked for. +func (m *Manager) start(base context.Context, force bool, notify func(string)) string { + m.mu.Lock() + if m.running { + last, hasLast := m.last, m.hasLast + m.mu.Unlock() + if hasLast { + return "A Codex login is already pending — finish this one:\n\n" + promptText(last) + } + return "A Codex login is already starting; the link and code arrive in a moment." + } + // credentialsPresent, not Authorized: with CODEX_REQUIRE_AUTH=false the policy + // answer is always "authorized", which would make /login unusable on exactly + // the deployments most likely to need it. + if !force && m.credentialsPresent() { + m.mu.Unlock() + return m.StatusText() + "\n\nSend /login force to sign in again." + } + + // context.WithoutCancel: the login must survive the update whose handler + // started it. The timeout is the real bound. + ctx, cancel := context.WithTimeout(context.WithoutCancel(base), m.cfg.timeout()) + m.running = true + m.cancel = cancel + m.hasLast = false + m.last = Prompt{} + m.mu.Unlock() + + go m.run(ctx, cancel, notify) + return "Starting the Codex sign-in — the link and one-time code arrive in a moment." +} + +// run drives one login attempt and reports its result through notify. +func (m *Manager) run(ctx context.Context, cancel context.CancelFunc, notify func(string)) { + defer cancel() + log := m.cfg.logger() + + err := Login(ctx, m.cfg, func(p Prompt) { + m.mu.Lock() + m.last, m.hasLast = p, true + m.mu.Unlock() + log.Info("codex device login prompt issued", "url", p.URL, "expires_in", p.ExpiresIn) + if notify != nil { + notify(promptText(p)) + } + }) + + m.mu.Lock() + m.running = false + m.cancel = nil + m.mu.Unlock() + + if notify == nil { + return + } + if err == nil { + log.Info("codex device login succeeded", "codex_home", m.cfg.Home) + notify("Codex is authorized. Send your next message and the team gets to work.") + return + } + log.Warn("codex device login failed", "error", err) + notify(failureText(err)) +} + +// Cancel aborts a pending login, reporting whether there was one. +func (m *Manager) Cancel() bool { + if m == nil { + return false + } + m.mu.Lock() + defer m.mu.Unlock() + if !m.running || m.cancel == nil { + return false + } + m.cancel() + m.cancel = nil + return true +} + +// promptText renders the user-facing sign-in instructions. The code is the whole +// point of the message, so it goes on its own line, unadorned, ready to copy. +func promptText(p Prompt) string { + var b strings.Builder + _, _ = b.WriteString("Codex sign-in — two steps:\n\n1. Open this link and sign in to your ChatGPT account:\n") + _, _ = b.WriteString(p.URL) + _, _ = b.WriteString("\n\n2. Enter this one-time code there:\n") + _, _ = b.WriteString(p.Code) + if p.ExpiresIn > 0 { + _, _ = b.WriteString(fmt.Sprintf("\n\nThe code expires in %s.", humanDuration(p.ExpiresIn))) + } + _, _ = b.WriteString("\n\nI'll confirm here as soon as it goes through. /login cancel aborts it.") + return b.String() +} + +// failureText turns a login error into an actionable reply, keeping the CLI's +// own words for the cases we cannot classify. +func failureText(err error) string { + switch { + case errors.Is(err, context.DeadlineExceeded): + return "The Codex sign-in timed out — the one-time code expired before it was confirmed. " + + "Send /login to get a fresh code." + case errors.Is(err, context.Canceled): + return "The Codex sign-in was cancelled." + case errors.Is(err, ErrDeviceAuthUnsupported): + return "Codex sign-in is unavailable: " + err.Error() + default: + return "Codex sign-in failed: " + err.Error() + "\n\nSend /login to try again." + } +} + +// humanDuration renders a code lifetime the way a person would say it. +func humanDuration(d time.Duration) string { + switch { + case d >= time.Hour: + return fmt.Sprintf("%d h", int(d.Hours())) + case d >= time.Minute: + return fmt.Sprintf("%d min", int(d.Minutes())) + default: + return fmt.Sprintf("%d s", int(d.Seconds())) + } +} diff --git a/core/codexauth/codexauth_test.go b/core/codexauth/codexauth_test.go new file mode 100644 index 0000000..9618f70 --- /dev/null +++ b/core/codexauth/codexauth_test.go @@ -0,0 +1,400 @@ +package codexauth + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// writeAuthFile plants the auth.json a completed Codex login leaves behind. +func writeAuthFile(t *testing.T, home string) { + t.Helper() + if err := os.WriteFile(filepath.Join(home, "auth.json"), []byte(`{"tokens":{}}`), 0o600); err != nil { + t.Fatalf("write auth.json: %v", err) + } +} + +// collector accumulates the background notices a login delivers. +type collector struct { + ch chan string +} + +func newCollector() *collector { return &collector{ch: make(chan string, 8)} } + +func (c *collector) notify(text string) { c.ch <- text } + +// next returns the next notice, failing the test if none arrives in time. +func (c *collector) next(t *testing.T) string { + t.Helper() + select { + case s := <-c.ch: + return s + case <-time.After(10 * time.Second): + t.Fatal("no login notice within 10s") + return "" + } +} + +// TestApplicableOnlyForCodexSubscription: every other setup authenticates from +// env alone, so /login must say so instead of spawning a CLI. +func TestApplicableOnlyForCodexSubscription(t *testing.T) { + tests := []struct { + name string + backend string + authMode string + want bool + }{ + {"codex subscription", BackendCodex, AuthSubscription, true}, + {"codex subscription mixed case", "Codex", "Subscription", true}, + {"codex billing", BackendCodex, AuthBilling, false}, + {"claude", "claude", AuthSubscription, false}, + {"openai-compatible", "openai-compat", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := NewManager(Config{Backend: tt.backend, AuthMode: tt.authMode}) + if got := m.Applicable(); got != tt.want { + t.Errorf("Applicable() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestAuthorizedSources: a persisted auth.json or a configured access token each +// authorize Codex; neither present means the deployment needs a login. +func TestAuthorizedSources(t *testing.T) { + t.Run("no credentials", func(t *testing.T) { + m := NewManager(Config{Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: t.TempDir()}) + if m.Authorized() { + t.Error("Authorized() = true with no auth.json and no access token") + } + if !m.NeedsLogin() { + t.Error("NeedsLogin() = false, want true") + } + }) + t.Run("persisted auth file", func(t *testing.T) { + home := t.TempDir() + writeAuthFile(t, home) + m := NewManager(Config{Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: home}) + if !m.Authorized() { + t.Error("Authorized() = false with a persisted auth.json") + } + }) + t.Run("access token", func(t *testing.T) { + m := NewManager(Config{ + Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: t.TempDir(), HasAccessToken: true, + }) + if !m.Authorized() { + t.Error("Authorized() = false with CODEX_ACCESS_TOKEN configured") + } + }) + t.Run("non-codex backend never needs a login", func(t *testing.T) { + m := NewManager(Config{Backend: "claude"}) + if !m.Authorized() { + t.Error("Authorized() = false for a non-Codex backend") + } + }) +} + +// TestAuthorizedRechecksTheAuthFile is what lets the very first message after a +// successful /login run WITHOUT a restart: the verdict is re-read from disk, not +// cached from startup. +func TestAuthorizedRechecksTheAuthFile(t *testing.T) { + home := t.TempDir() + m := NewManager(Config{Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: home}) + if m.Authorized() { + t.Fatal("Authorized() = true before any login") + } + writeAuthFile(t, home) + if !m.Authorized() { + t.Error("Authorized() = false after auth.json appeared; the verdict was cached") + } +} + +// TestBlockedNotice: an unauthorized deployment blocks runs with a notice naming +// the one command that fixes it, and never blocks once authorized. +func TestBlockedNotice(t *testing.T) { + home := t.TempDir() + m := NewManager(Config{Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: home}) + + notice, blocked := m.BlockedNotice() + if !blocked { + t.Fatal("BlockedNotice() reported no block while unauthorized") + } + if !strings.Contains(notice, "/login") { + t.Errorf("notice %q should tell the user to send /login", notice) + } + + writeAuthFile(t, home) + if _, blocked := m.BlockedNotice(); blocked { + t.Error("BlockedNotice() still blocks after a completed login") + } +} + +// TestNilManagerIsInert: an adapter wired without a Manager must degrade to "no +// login needed" rather than panic. +func TestNilManagerIsInert(t *testing.T) { + var m *Manager + if m.Applicable() { + t.Error("nil Manager reported Applicable") + } + if !m.Authorized() { + t.Error("nil Manager reported unauthorized") + } + if _, blocked := m.BlockedNotice(); blocked { + t.Error("nil Manager blocked a run") + } + if m.Cancel() { + t.Error("nil Manager cancelled a login") + } + if got := m.Dispatch(context.Background(), "", nil); got != NoLoginNeededText { + t.Errorf("nil Manager Dispatch = %q, want NoLoginNeededText", got) + } +} + +// TestDispatchNotApplicable: /login on a Claude or billing deployment explains +// itself instead of running anything. +func TestDispatchNotApplicable(t *testing.T) { + claude := NewManager(Config{Backend: "claude"}) + if got := claude.Dispatch(context.Background(), "", nil); !strings.Contains(got, "claude") { + t.Errorf("Dispatch = %q, want it to name the configured backend", got) + } + billing := NewManager(Config{Backend: BackendCodex, AuthMode: AuthBilling}) + if got := billing.Dispatch(context.Background(), "", nil); !strings.Contains(got, "CODEX_API_KEY") { + t.Errorf("Dispatch = %q, want the billing-mode explanation", got) + } +} + +// TestDispatchFullLoginPath walks the whole user-visible journey the feature +// exists for: an unauthorized deployment blocks runs, /login hands back a link +// and a one-time code while the CLI is still polling, the completed sign-in is +// confirmed, and the standard flow is unblocked — no restart involved. +func TestDispatchFullLoginPath(t *testing.T) { + home := t.TempDir() + // The fake CLI prints the real banner, then "completes" the browser step by + // writing the auth.json a real login would persist. + bin := fakeCodex(t, printBanner+"sleep 0.2\necho '{}' > \"$CODEX_HOME/auth.json\"\nexit 0\n") + m := NewManager(Config{ + Backend: BackendCodex, + AuthMode: AuthSubscription, + RequireAuth: true, + Bin: bin, + Home: home, + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + + if _, blocked := m.BlockedNotice(); !blocked { + t.Fatal("runs were not blocked before the login") + } + + c := newCollector() + if reply := m.Dispatch(context.Background(), "", c.notify); !strings.Contains(reply, "Starting") { + t.Errorf("immediate reply = %q, want an acknowledgement that the sign-in started", reply) + } + + prompt := c.next(t) + if !strings.Contains(prompt, "https://auth.openai.com/codex/device") { + t.Errorf("prompt %q is missing the verification link", prompt) + } + if !strings.Contains(prompt, "XER9-NWCA2") { + t.Errorf("prompt %q is missing the one-time code", prompt) + } + if !strings.Contains(prompt, "15 min") { + t.Errorf("prompt %q is missing the code lifetime", prompt) + } + + done := c.next(t) + if !strings.Contains(strings.ToLower(done), "authorized") { + t.Errorf("final notice = %q, want a success confirmation", done) + } + if _, blocked := m.BlockedNotice(); blocked { + t.Error("runs are still blocked after a successful login") + } +} + +// TestDispatchReshowsPendingPrompt: a second /login while one is pending must +// re-show the SAME code, not start a competing flow that would invalidate it. +func TestDispatchReshowsPendingPrompt(t *testing.T) { + runs := filepath.Join(t.TempDir(), "runs") + bin := fakeCodex(t, "echo x >> "+runs+"\n"+printBanner+"sleep 30\n") + m := NewManager(Config{ + Backend: BackendCodex, + AuthMode: AuthSubscription, + RequireAuth: true, + Bin: bin, + Home: t.TempDir(), + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + t.Cleanup(func() { m.Cancel() }) + + c := newCollector() + m.Dispatch(context.Background(), "", c.notify) + if prompt := c.next(t); !strings.Contains(prompt, "XER9-NWCA2") { + t.Fatalf("first prompt %q is missing the code", prompt) + } + + again := m.Dispatch(context.Background(), "", c.notify) + if !strings.Contains(again, "XER9-NWCA2") { + t.Errorf("second /login = %q, want the pending code re-shown", again) + } + if !strings.Contains(again, "already pending") { + t.Errorf("second /login = %q, want it to say a login is already pending", again) + } + + data, err := os.ReadFile(runs) + if err != nil { + t.Fatalf("read run log: %v", err) + } + if got := strings.Count(string(data), "x"); got != 1 { + t.Errorf("the CLI ran %d times, want 1: a second login would invalidate the first code", got) + } +} + +// TestDispatchCancel aborts a pending sign-in and reports the cancellation. +func TestDispatchCancel(t *testing.T) { + bin := fakeCodex(t, printBanner+"sleep 30\n") + m := NewManager(Config{ + Backend: BackendCodex, + AuthMode: AuthSubscription, + RequireAuth: true, + Bin: bin, + Home: t.TempDir(), + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + + if got := m.Dispatch(context.Background(), "cancel", nil); !strings.Contains(got, "No Codex login is pending") { + t.Errorf("cancel with nothing pending = %q", got) + } + + c := newCollector() + m.Dispatch(context.Background(), "", c.notify) + c.next(t) // the prompt + + if got := m.Dispatch(context.Background(), "cancel", c.notify); !strings.Contains(got, "Cancelled") { + t.Errorf("cancel = %q, want a cancellation acknowledgement", got) + } + if got := c.next(t); !strings.Contains(got, "cancelled") { + t.Errorf("final notice = %q, want the cancellation reported", got) + } +} + +// TestDispatchAlreadyAuthorized: /login on an authorized deployment reports the +// state and offers the explicit re-login rather than silently replacing a +// working credential. +func TestDispatchAlreadyAuthorized(t *testing.T) { + home := t.TempDir() + writeAuthFile(t, home) + m := NewManager(Config{Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: home}) + + got := m.Dispatch(context.Background(), "", nil) + if !strings.Contains(got, "authorized") || !strings.Contains(got, "/login force") { + t.Errorf("Dispatch = %q, want the authorized state plus the force hint", got) + } +} + +// TestDispatchStatusAndUsage covers the read-only arguments. +func TestDispatchStatusAndUsage(t *testing.T) { + m := NewManager(Config{Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: t.TempDir()}) + + if got := m.Dispatch(context.Background(), "status", nil); !strings.Contains(got, "NOT authorized") { + t.Errorf("status = %q, want the unauthorized state", got) + } + if got := m.Dispatch(context.Background(), "help", nil); got != LoginUsage { + t.Errorf("help = %q, want LoginUsage", got) + } + if got := m.Dispatch(context.Background(), "wat", nil); !strings.Contains(got, "Unknown /login argument") { + t.Errorf("unknown arg = %q, want the usage hint", got) + } +} + +// TestStatusTextNeverLeaksSecrets: an access-token deploy reports only THAT one +// is configured. +func TestStatusTextNeverLeaksSecrets(t *testing.T) { + m := NewManager(Config{ + Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: t.TempDir(), HasAccessToken: true, + }) + got := m.StatusText() + if !strings.Contains(got, "access token") { + t.Errorf("StatusText = %q, want it to mention the configured access token", got) + } +} + +// TestDispatchSurvivesADeadRequestContext is the guarantee that makes the whole +// flow usable: the update whose handler ran /login is finished (and its context +// cancelled) long before a human finishes signing in, so the login must NOT be +// tied to it. +func TestDispatchSurvivesADeadRequestContext(t *testing.T) { + bin := fakeCodex(t, printBanner+"sleep 0.3\nexit 0\n") + m := NewManager(Config{ + Backend: BackendCodex, + AuthMode: AuthSubscription, + RequireAuth: true, + Bin: bin, + Home: t.TempDir(), + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + + ctx, cancel := context.WithCancel(context.Background()) + c := newCollector() + m.Dispatch(ctx, "", c.notify) + cancel() // the update's context dies immediately, as it does in production + + if prompt := c.next(t); !strings.Contains(prompt, "XER9-NWCA2") { + t.Errorf("prompt %q lost after the request context was cancelled", prompt) + } + if done := c.next(t); !strings.Contains(strings.ToLower(done), "authorized") { + t.Errorf("final notice = %q, want the login to have completed anyway", done) + } +} + +// TestRequireAuthFalseNeverBlocks: CODEX_REQUIRE_AUTH=false is an explicit +// operator opt-out — they have taken responsibility for credentials this process +// cannot observe (a home mounted later, config.toml). Blocking their runs would +// break a deployment that used to work, so the gate must stay open while /login +// stays offered. +func TestRequireAuthFalseNeverBlocks(t *testing.T) { + m := NewManager(Config{ + Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: false, Home: t.TempDir(), + }) + + if !m.Authorized() { + t.Error("Authorized() = false with CODEX_REQUIRE_AUTH=false") + } + if _, blocked := m.BlockedNotice(); blocked { + t.Error("runs were blocked with CODEX_REQUIRE_AUTH=false") + } + status := m.StatusText() + if !strings.Contains(status, "CODEX_REQUIRE_AUTH=false") { + t.Errorf("status = %q, want it to explain why nothing is blocked", status) + } + // /login must still be usable: without a persisted credential it starts a real + // sign-in rather than reporting "already authorized". + if got := m.StatusText(); strings.Contains(got, "already") { + t.Errorf("status = %q, want it not to claim an existing login", got) + } +} + +// TestSubscriptionLoginDropsInheritedAPIKey: a stray CODEX_API_KEY in the parent +// environment must never reach a subscription sign-in — that is the one path that +// could silently convert a subscription deploy to usage-based API billing. +func TestSubscriptionLoginDropsInheritedAPIKey(t *testing.T) { + cfg := Config{ + Backend: BackendCodex, AuthMode: AuthSubscription, Home: t.TempDir(), + Env: []string{"PATH=/usr/bin", "CODEX_API_KEY=sk-live", "CODEX_HOME=/elsewhere"}, + } + env := cfg.childEnv() + for _, kv := range env { + if strings.HasPrefix(kv, "CODEX_API_KEY=") { + t.Error("CODEX_API_KEY reached a subscription login") + } + if kv == "CODEX_HOME=/elsewhere" { + t.Error("the inherited CODEX_HOME was not overridden") + } + } + if env[len(env)-1] != "CODEX_HOME="+cfg.Home { + t.Errorf("CODEX_HOME = %q, want the configured home", env[len(env)-1]) + } +} diff --git a/core/codexauth/login.go b/core/codexauth/login.go new file mode 100644 index 0000000..7aa3163 --- /dev/null +++ b/core/codexauth/login.go @@ -0,0 +1,385 @@ +// Package codexauth drives the Codex CLI's DEVICE-CODE login from a chat, so a +// headless deploy (container, VPS) can be authorized without a shell on the host. +// +// Why device code and not plain `codex login`: the plain flow starts a loopback +// OAuth server on http://localhost:1455 and prints the authorize URL whose +// redirect_uri points back at that loopback. Inside a container nobody can reach +// it — the operator opens the URL on their laptop, the callback lands on THEIR +// localhost, and the CLI waits for a callback that never arrives. That is the +// "no code is shown and the CLI hangs forever" symptom. Codex itself says so on +// stderr ("On a remote or headless machine? Use `codex login --device-auth`"). +// +// `codex login --device-auth` instead prints, on STDOUT, a verification URL plus +// a one-time code and then BLOCKS, polling the token endpoint until the user +// finishes in a browser. Two consequences shape this package: +// +// 1. The URL and code must be read INCREMENTALLY from the live pipe. Anything +// that waits for the process to exit (cmd.Output/CombinedOutput) shows +// nothing at all until the login is already over — the same apparent hang. +// 2. Codex colors that output with ANSI escapes even when stdout is not a TTY, +// so the text has to be de-escaped before the URL and code can be matched. +package codexauth + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os/exec" + "regexp" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +// DeviceCodeTTL is how long Codex says a one-time code stays valid. The login +// process is capped slightly above it so an abandoned login cannot leak a child +// process for longer than the code it is waiting on. +const DeviceCodeTTL = 15 * time.Minute + +// killGrace is how long the child gets to exit after SIGTERM before SIGKILL. +const killGrace = 500 * time.Millisecond + +// outputTailBytes is how much CLI output we keep to enrich a failure message. +const outputTailBytes = 4096 + +// ErrDeviceAuthUnsupported reports a Codex CLI too old to know --device-auth. +// Without it there is no headless login path at all, so the operator has to +// upgrade the CLI rather than retry. +var ErrDeviceAuthUnsupported = errors.New( + "this Codex CLI does not support `codex login --device-auth`; upgrade the Codex CLI to authorize headlessly") + +// ErrNoPrompt reports that Codex exited before printing a verification URL and +// one-time code. The wrapped message carries the captured CLI output. +var ErrNoPrompt = errors.New("codex printed no device-login code") + +// Prompt is the user-actionable half of a device login: open URL in a browser, +// sign in, and enter Code there. ExpiresIn is Codex's own stated validity window +// (zero when it did not state one). +type Prompt struct { + URL string + Code string + ExpiresIn time.Duration +} + +// Login runs `codex login --device-auth` to completion and reports the outcome. +// +// onPrompt is called ONCE, as soon as the verification URL and one-time code have +// been parsed off the live output — long before the call returns — so the caller +// can relay them to the user while the CLI keeps polling. It may be nil. It runs +// ON the output-reading goroutine, which keeps it strictly ordered before the +// outcome the caller reports afterwards; in exchange it must not block +// indefinitely (adapters send their notice under a bounded context). +// +// Login blocks until the user finishes in the browser (nil), until Codex fails +// (error), or until ctx is done. On ctx cancellation the whole child process +// GROUP is signalled, so no polling child is left behind, and ctx.Err() is +// returned. Success is defined by Codex exiting 0, which is also when auth.json +// under CODEX_HOME has been written. +func Login(ctx context.Context, cfg Config, onPrompt func(Prompt)) error { + //nolint:gosec,noctx // bin is operator config; ctx drives the process-group kill below. + cmd := exec.Command(cfg.bin(), "login", "--device-auth") + cmd.Env = cfg.childEnv() + + // The device flow needs no stdin, but an inherited terminal would let the CLI + // block on a read. A closed stdin keeps it strictly non-interactive. + cmd.Stdin = nil + // Own process group: cancelling must kill the poller, not just the shell. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("codexauth: stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("codexauth: stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("codexauth: start %s: %w", cfg.bin(), err) + } + + done := make(chan struct{}) + var ( + killMu sync.Mutex + killTimer *time.Timer + ) + var watch sync.WaitGroup + watch.Add(1) + go func() { + defer watch.Done() + select { + case <-ctx.Done(): + t := killGroup(cmd) + killMu.Lock() + killTimer = t + killMu.Unlock() + case <-done: + } + }() + + // Both streams are parsed: --device-auth prints the prompt on stdout, while + // diagnostics (including the "use --device-auth" hint and argument errors) + // arrive on stderr. Whichever carries the URL and code, we find it. + out := newTail(outputTailBytes) + var parseMu sync.Mutex + parser := &promptParser{} + var streams sync.WaitGroup + for _, rd := range []io.Reader{stdout, stderr} { + streams.Add(1) + go func(rd io.Reader) { + defer streams.Done() + scanLines(rd, func(line string) { + out.WriteString(line + "\n") + parseMu.Lock() + p, ok := parser.feed(line) + parseMu.Unlock() + if ok && onPrompt != nil { + onPrompt(p) + } + }) + }(rd) + } + streams.Wait() + + waitErr := cmd.Wait() + close(done) + watch.Wait() + killMu.Lock() + if killTimer != nil { + killTimer.Stop() + } + killMu.Unlock() + + parseMu.Lock() + sawPrompt := parser.sent + parseMu.Unlock() + + return loginResult(ctx, waitErr, out.String(), sawPrompt) +} + +// loginResult maps the child's exit onto the caller-facing error contract: a +// cancelled/expired ctx wins over any exit status (the kill caused it), an +// unknown --device-auth flag is reported as its own sentinel so the caller can +// tell the operator to upgrade, and a clean exit that never printed a prompt is +// still a failure — the user was never given anything to act on. +func loginResult(ctx context.Context, waitErr error, output string, sawPrompt bool) error { + if ctx.Err() != nil { + return ctx.Err() + } + if isUnsupportedFlag(output) { + return ErrDeviceAuthUnsupported + } + if waitErr != nil { + var ee *exec.ExitError + if errors.As(waitErr, &ee) { + return fmt.Errorf("codex login exited with code %d: %s", ee.ExitCode(), condense(output)) + } + return fmt.Errorf("codex login failed: %w: %s", waitErr, condense(output)) + } + if !sawPrompt { + return fmt.Errorf("%w: %s", ErrNoPrompt, condense(output)) + } + return nil +} + +// isUnsupportedFlag detects a Codex CLI that does not know --device-auth. clap +// (the CLI parser Codex uses) words this as "unexpected argument"; older/other +// builds say "unrecognized". Matching either keeps the check robust without +// pinning a version. +func isUnsupportedFlag(output string) bool { + low := strings.ToLower(output) + if !strings.Contains(low, "device-auth") { + return false + } + return strings.Contains(low, "unexpected argument") || + strings.Contains(low, "unrecognized") || + strings.Contains(low, "unknown flag") || + strings.Contains(low, "invalid value") +} + +// condense collapses captured CLI output into one line for an error message, +// dropping blank lines so a mostly-decorative banner does not bury the cause. +func condense(output string) string { + fields := strings.FieldsFunc(output, func(r rune) bool { return r == '\n' || r == '\r' }) + kept := make([]string, 0, len(fields)) + for _, f := range fields { + if f = strings.TrimSpace(f); f != "" { + kept = append(kept, f) + } + } + if len(kept) == 0 { + return "(no output)" + } + return strings.Join(kept, " | ") +} + +// scanLines feeds rd's output to onLine one ANSI-stripped line at a time, +// splitting on \n AND \r so a progress line rewritten in place still surfaces. +// It returns when rd hits EOF. +func scanLines(rd io.Reader, onLine func(string)) { + sc := bufio.NewScanner(rd) + sc.Buffer(make([]byte, 0, 4096), 1<<20) + sc.Split(splitLines) + for sc.Scan() { + line := stripANSI(sc.Text()) + if strings.TrimSpace(line) == "" { + continue + } + onLine(line) + } +} + +// splitLines is a bufio.SplitFunc that breaks on either \n or \r, so output the +// CLI redraws with a carriage return is not withheld until the next newline. +func splitLines(data []byte, atEOF bool) (advance int, token []byte, err error) { + for i, b := range data { + if b == '\n' || b == '\r' { + return i + 1, data[:i], nil + } + } + if atEOF && len(data) > 0 { + return len(data), data, nil + } + return 0, nil, nil +} + +// ansiRe matches CSI/OSC-style escape sequences. Codex colors its login output +// even when stdout is a pipe, so the raw text carries escapes around exactly the +// two tokens we need (the URL and the code) and must be cleaned before matching. +var ansiRe = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)`) + +// stripANSI removes terminal escape sequences from s. +func stripANSI(s string) string { return ansiRe.ReplaceAllString(s, "") } + +var ( + // urlRe matches the verification link. Trailing punctuation is trimmed by the + // character class so a URL at the end of a sentence stays intact. + urlRe = regexp.MustCompile(`https?://[^\s"'<>)\]]+`) + // codeRe matches a one-time code such as "XER9-NWCA2". + codeRe = regexp.MustCompile(`\b[A-Z0-9]{4,8}-[A-Z0-9]{4,8}\b`) + // ttlRe matches Codex's stated validity window, e.g. "(expires in 15 minutes)". + ttlRe = regexp.MustCompile(`(?i)expires in (\d+) (second|minute|hour)s?`) +) + +// promptParser accumulates the device-login prompt across the CLI's output +// lines. Codex prints the URL and the code on separate lines, so neither alone +// is actionable; the prompt is released exactly once, on the line that completes +// the pair. +type promptParser struct { + url string + code string + ttl time.Duration + sent bool +} + +// feed consumes one ANSI-stripped output line. ok is true only for the line that +// first completes the URL+code pair. +func (p *promptParser) feed(line string) (Prompt, bool) { + if p.sent { + return Prompt{}, false + } + if p.url == "" { + if m := urlRe.FindString(line); m != "" { + p.url = strings.TrimRight(m, ".,;") + } + } + if p.code == "" { + if c, ok := matchCode(line); ok { + p.code = c + } + } + if p.ttl == 0 { + p.ttl = matchTTL(line) + } + if p.url == "" || p.code == "" { + return Prompt{}, false + } + p.sent = true + return Prompt{URL: p.url, Code: p.code, ExpiresIn: p.ttl}, true +} + +// matchCode extracts a one-time code from line. A bare uppercase hyphenated +// token is only accepted when it is the WHOLE line (how Codex prints it) or when +// the line is explicitly about a code — otherwise an unrelated token in a banner +// or inside a URL could be mistaken for one. +func matchCode(line string) (string, bool) { + trimmed := strings.TrimSpace(line) + if urlRe.MatchString(trimmed) { + return "", false + } + m := codeRe.FindString(trimmed) + if m == "" { + return "", false + } + if m == trimmed || strings.Contains(strings.ToLower(trimmed), "code") { + return m, true + } + return "", false +} + +// matchTTL reads Codex's stated code lifetime off a line, returning 0 when the +// line does not state one. +func matchTTL(line string) time.Duration { + m := ttlRe.FindStringSubmatch(line) + if m == nil { + return 0 + } + n, err := strconv.Atoi(m[1]) + if err != nil || n <= 0 { + return 0 + } + switch strings.ToLower(m[2]) { + case "second": + return time.Duration(n) * time.Second + case "hour": + return time.Duration(n) * time.Hour + default: + return time.Duration(n) * time.Minute + } +} + +// killGroup signals the child's whole process group, escalating to SIGKILL after +// killGrace. It mirrors core/codex's runner so a cancelled login cannot leave a +// polling Codex process behind. +func killGroup(cmd *exec.Cmd) *time.Timer { + if cmd.Process == nil { + return nil + } + pgid := cmd.Process.Pid + _ = syscall.Kill(-pgid, syscall.SIGTERM) + return time.AfterFunc(killGrace, func() { + _ = syscall.Kill(-pgid, syscall.SIGKILL) + }) +} + +// tail is a bounded io.Writer keeping only the last max bytes written, used to +// enrich failures with the CLI's own words without unbounded buffering. +type tail struct { + mu sync.Mutex + max int + buf []byte +} + +func newTail(max int) *tail { return &tail{max: max} } + +// WriteString appends s, dropping the oldest bytes past the cap. +func (t *tail) WriteString(s string) { + t.mu.Lock() + defer t.mu.Unlock() + t.buf = append(t.buf, s...) + if len(t.buf) > t.max { + t.buf = t.buf[len(t.buf)-t.max:] + } +} + +// String returns the retained tail. +func (t *tail) String() string { + t.mu.Lock() + defer t.mu.Unlock() + return string(t.buf) +} diff --git a/core/codexauth/login_test.go b/core/codexauth/login_test.go new file mode 100644 index 0000000..3816d99 --- /dev/null +++ b/core/codexauth/login_test.go @@ -0,0 +1,342 @@ +package codexauth + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// deviceAuthBanner is what `codex login --device-auth` really prints, captured +// from the CLI: an ANSI-colored banner, the verification URL, and the one-time +// code on its own line. The escapes are REAL — surviving them is the point. +const deviceAuthBanner = "\n" + + "Welcome to Codex [v\x1b[90m0.146.0\x1b[0m]\n" + + "\x1b[90mOpenAI's command-line coding agent\x1b[0m\n" + + "\n" + + "Follow these steps to sign in with ChatGPT using device code authorization:\n" + + "\n" + + "1. Open this link in your browser and sign in to your account\n" + + " \x1b[94mhttps://auth.openai.com/codex/device\x1b[0m\n" + + "\n" + + "2. Enter this one-time code \x1b[90m(expires in 15 minutes)\x1b[0m\n" + + " \x1b[94mXER9-NWCA2\x1b[0m\n" + +// printBanner is the shell snippet emitting deviceAuthBanner verbatim. A quoted +// heredoc keeps the apostrophes and escapes intact without shell interpretation. +const printBanner = "cat <<'CODEX_BANNER_EOF'" + deviceAuthBanner + "CODEX_BANNER_EOF\n" + +// fakeCodex writes an executable stand-in for the Codex CLI whose body is the +// given shell script, and returns its path. The script receives the same +// arguments the real CLI would. +func fakeCodex(t *testing.T, script string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the fake CLI is a POSIX shell script") + } + path := filepath.Join(t.TempDir(), "codex") + body := "#!/bin/sh\n" + script + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { //nolint:gosec // test fixture must be executable + t.Fatalf("write fake codex: %v", err) + } + return path +} + +// testConfig points a Config at the fake CLI with a scratch CODEX_HOME and a +// minimal environment (an empty Env would inherit the test runner's). +func testConfig(t *testing.T, bin string) Config { + t.Helper() + return Config{ + Backend: BackendCodex, + AuthMode: AuthSubscription, + Bin: bin, + Home: t.TempDir(), + Env: []string{"PATH=" + os.Getenv("PATH")}, + } +} + +// TestLoginEmitsPromptWhileProcessStillRunning is the core regression for the +// reported symptom: with `codex login --device-auth` the URL and code are printed +// and the CLI THEN blocks for minutes, polling. Anything that waits for the +// process to exit before reading (cmd.Output/CombinedOutput) shows the user +// nothing and looks like a hang. The prompt must arrive while the child is still +// running. +func TestLoginEmitsPromptWhileProcessStillRunning(t *testing.T) { + bin := fakeCodex(t, printBanner+"sleep 30\n") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + prompts := make(chan Prompt, 1) + errs := make(chan error, 1) + go func() { + errs <- Login(ctx, testConfig(t, bin), func(p Prompt) { prompts <- p }) + }() + + var got Prompt + select { + case got = <-prompts: + case err := <-errs: + t.Fatalf("Login returned %v before emitting a prompt", err) + case <-time.After(10 * time.Second): + t.Fatal("no prompt within 10s: the CLI output is not being read incrementally") + } + + if got.URL != "https://auth.openai.com/codex/device" { + t.Errorf("URL = %q, want the device verification link", got.URL) + } + if got.Code != "XER9-NWCA2" { + t.Errorf("Code = %q, want XER9-NWCA2", got.Code) + } + if got.ExpiresIn != 15*time.Minute { + t.Errorf("ExpiresIn = %v, want 15m", got.ExpiresIn) + } + + // Cancelling must reap the still-polling child and report the ctx error. + cancel() + select { + case err := <-errs: + if !errors.Is(err, context.Canceled) { + t.Errorf("Login after cancel = %v, want context.Canceled", err) + } + case <-time.After(10 * time.Second): + t.Fatal("Login did not return after cancel: the child was not killed") + } +} + +// TestLoginSuccess: a CLI that prints the prompt and exits 0 is a completed +// sign-in. +func TestLoginSuccess(t *testing.T) { + bin := fakeCodex(t, printBanner+"exit 0\n") + var got Prompt + err := Login(context.Background(), testConfig(t, bin), func(p Prompt) { got = p }) + if err != nil { + t.Fatalf("Login = %v, want nil", err) + } + if got.Code == "" { + t.Error("onPrompt was never called") + } +} + +// TestLoginDeviceAuthUnsupported: a Codex CLI too old to know --device-auth has +// no headless login path at all, so the caller must be told to upgrade rather +// than to retry. +func TestLoginDeviceAuthUnsupported(t *testing.T) { + bin := fakeCodex(t, "echo \"error: unexpected argument '--device-auth' found\" >&2\nexit 2\n") + err := Login(context.Background(), testConfig(t, bin), nil) + if !errors.Is(err, ErrDeviceAuthUnsupported) { + t.Fatalf("Login = %v, want ErrDeviceAuthUnsupported", err) + } +} + +// TestLoginNoPrompt: a clean exit that never printed a code is still a failure — +// the user was given nothing to act on — and the error carries the CLI's output. +func TestLoginNoPrompt(t *testing.T) { + bin := fakeCodex(t, "echo 'nothing useful here'\nexit 0\n") + err := Login(context.Background(), testConfig(t, bin), nil) + if !errors.Is(err, ErrNoPrompt) { + t.Fatalf("Login = %v, want ErrNoPrompt", err) + } + if !strings.Contains(err.Error(), "nothing useful here") { + t.Errorf("error %q should quote the CLI output", err) + } +} + +// TestLoginFailureQuotesOutput: a non-zero exit reports the code and the CLI's +// own words, including stderr. +func TestLoginFailureQuotesOutput(t *testing.T) { + bin := fakeCodex(t, "echo 'network unreachable' >&2\nexit 7\n") + err := Login(context.Background(), testConfig(t, bin), nil) + if err == nil { + t.Fatal("Login = nil, want an error") + } + if !strings.Contains(err.Error(), "7") || !strings.Contains(err.Error(), "network unreachable") { + t.Errorf("error %q should carry the exit code and stderr", err) + } +} + +// TestLoginForcesConfiguredCodexHome: the login must write auth.json where the +// RUNNER later looks for it. An inherited CODEX_HOME pointing elsewhere would +// leave the deployment "logged in" and unauthorized at the same time. +func TestLoginForcesConfiguredCodexHome(t *testing.T) { + bin := fakeCodex(t, printBanner+"echo \"$CODEX_HOME\" > \"$CODEX_HOME/seen\"\nexit 0\n") + cfg := testConfig(t, bin) + cfg.Env = append(cfg.Env, "CODEX_HOME=/somewhere/else") + + if err := Login(context.Background(), cfg, nil); err != nil { + t.Fatalf("Login = %v, want nil", err) + } + seen, err := os.ReadFile(filepath.Join(cfg.Home, "seen")) + if err != nil { + t.Fatalf("read CODEX_HOME probe: %v", err) + } + if strings.TrimSpace(string(seen)) != cfg.Home { + t.Errorf("child CODEX_HOME = %q, want %q", strings.TrimSpace(string(seen)), cfg.Home) + } +} + +// TestLoginTimeoutReported: the device code expires, so an abandoned login ends +// with the deadline rather than hanging forever. +func TestLoginTimeoutReported(t *testing.T) { + bin := fakeCodex(t, printBanner+"sleep 30\n") + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + err := Login(ctx, testConfig(t, bin), nil) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Login = %v, want context.DeadlineExceeded", err) + } +} + +// TestLoginMissingBinary: an unusable CODEX_BIN fails fast at start rather than +// leaving the caller waiting on a process that never existed. +func TestLoginMissingBinary(t *testing.T) { + cfg := testConfig(t, filepath.Join(t.TempDir(), "does-not-exist")) + if err := Login(context.Background(), cfg, nil); err == nil { + t.Fatal("Login = nil, want a start error") + } +} + +// TestStripANSI: Codex colors its login output even when stdout is a pipe, so the +// URL and code arrive wrapped in escapes. +func TestStripANSI(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"colored code", " \x1b[94mXER9-NWCA2\x1b[0m", " XER9-NWCA2"}, + {"plain text", "no escapes here", "no escapes here"}, + {"osc sequence", "\x1b]0;title\x07done", "done"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := stripANSI(tt.in); got != tt.want { + t.Errorf("stripANSI(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +// TestPromptParser covers the pairing rule: neither the URL nor the code alone is +// actionable, so the prompt is released exactly once, on the line completing the +// pair, and never again. +func TestPromptParser(t *testing.T) { + p := &promptParser{} + if _, ok := p.feed("1. Open this link"); ok { + t.Fatal("a prose line released a prompt") + } + if _, ok := p.feed(" https://auth.openai.com/codex/device"); ok { + t.Fatal("the URL alone released a prompt") + } + if _, ok := p.feed("2. Enter this one-time code (expires in 15 minutes)"); ok { + t.Fatal("the TTL line alone released a prompt") + } + got, ok := p.feed(" XER9-NWCA2") + if !ok { + t.Fatal("the code line did not release the prompt") + } + if got.URL != "https://auth.openai.com/codex/device" || got.Code != "XER9-NWCA2" { + t.Errorf("prompt = %+v, want the parsed URL and code", got) + } + if got.ExpiresIn != 15*time.Minute { + t.Errorf("ExpiresIn = %v, want 15m", got.ExpiresIn) + } + if _, ok := p.feed(" ABCD-EFGH"); ok { + t.Error("a second prompt was released") + } +} + +// TestMatchCode pins the anti-false-positive rule: a hyphenated uppercase token is +// a code only when it is the whole line or the line is explicitly about a code — +// never when it comes out of a URL or an unrelated banner. +func TestMatchCode(t *testing.T) { + tests := []struct { + name string + line string + want string + }{ + {"bare code line", " XER9-NWCA2 ", "XER9-NWCA2"}, + {"labelled code", "Your one-time code: XER9-NWCA2 now", "XER9-NWCA2"}, + {"inside a url", "https://example.com/AAAA-BBBB", ""}, + {"unrelated banner token", "Release NOTES-DRAFT ready", ""}, + {"lowercase", " xer9-nwca2", ""}, + {"no hyphen", " XER9NWCA2", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := matchCode(tt.line) + if tt.want == "" { + if ok { + t.Errorf("matchCode(%q) = %q, want no match", tt.line, got) + } + return + } + if !ok || got != tt.want { + t.Errorf("matchCode(%q) = %q/%v, want %q", tt.line, got, ok, tt.want) + } + }) + } +} + +// TestMatchTTL reads Codex's stated code lifetime in each unit it might use. +func TestMatchTTL(t *testing.T) { + tests := []struct { + line string + want time.Duration + }{ + {"2. Enter this one-time code (expires in 15 minutes)", 15 * time.Minute}, + {"expires in 1 minute", time.Minute}, + {"expires in 90 seconds", 90 * time.Second}, + {"expires in 2 hours", 2 * time.Hour}, + {"no lifetime here", 0}, + {"expires in 0 minutes", 0}, + } + for _, tt := range tests { + t.Run(tt.line, func(t *testing.T) { + if got := matchTTL(tt.line); got != tt.want { + t.Errorf("matchTTL(%q) = %v, want %v", tt.line, got, tt.want) + } + }) + } +} + +// TestIsUnsupportedFlag distinguishes "this CLI cannot do device auth" from any +// other failure that merely mentions the flag. +func TestIsUnsupportedFlag(t *testing.T) { + tests := []struct { + name string + output string + want bool + }{ + {"clap wording", "error: unexpected argument '--device-auth' found", true}, + {"getopt wording", "unrecognized option: --device-auth", true}, + {"unrelated failure", "device-auth polling failed: network unreachable", false}, + {"other flag", "error: unexpected argument '--nope' found", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isUnsupportedFlag(tt.output); got != tt.want { + t.Errorf("isUnsupportedFlag(%q) = %v, want %v", tt.output, got, tt.want) + } + }) + } +} + +// TestSplitLinesBreaksOnCarriageReturn: a line the CLI redraws in place with \r +// must surface immediately instead of waiting for a newline that may never come. +func TestSplitLinesBreaksOnCarriageReturn(t *testing.T) { + advance, token, err := splitLines([]byte("waiting…\rdone\n"), false) + if err != nil { + t.Fatalf("splitLines err = %v", err) + } + if string(token) != "waiting…" { + t.Errorf("token = %q, want %q", token, "waiting…") + } + if advance != len("waiting…")+1 { + t.Errorf("advance = %d, want %d", advance, len("waiting…")+1) + } +} diff --git a/docs/codex-integration-plan.md b/docs/codex-integration-plan.md index 9cee53e..b3f6f15 100644 --- a/docs/codex-integration-plan.md +++ b/docs/codex-integration-plan.md @@ -145,21 +145,25 @@ Accepted credential sources: printf '%s' "$CODEX_ACCESS_TOKEN" | codex login --with-access-token ``` -3. Manual one-time setup with device auth: +3. In-chat device login: an allow-listed user sends `/login` and the bot relays + the verification link and one-time code (`core/codexauth`). This is the + primary path — it needs no shell on the host. +4. Manual one-time setup with device auth, for operators who do have a shell: ```bash docker exec -it codex login --device-auth ``` -4. Manual copy of a local `auth.json` into the persistent `CODEX_HOME` volume. +5. Manual copy of a local `auth.json` into the persistent `CODEX_HOME` volume. Validation rules: - If `CODEX_AUTH_MODE=subscription` and `CODEX_API_KEY` is non-empty, startup must fail with a clear error. This prevents accidental API billing. - If no persisted auth is present and no `CODEX_ACCESS_TOKEN` is supplied, the - bot may start only when `CODEX_REQUIRE_AUTH=false`; otherwise startup fails - with instructions to run `codex login --device-auth`. + bot starts in a "needs login" state rather than failing: runs are blocked with + a notice pointing at `/login`, which is the command that clears the state. + Failing startup here would be a deadlock — the login lives inside the bot. - `CODEX_ACCESS_TOKEN` must be treated as a secret and must never be copied into the per-chat workspace. @@ -496,22 +500,27 @@ Acceptance: current Claude template. - Claude rendering remains byte-compatible where practical. -### Stage 5 - Subscription Auth UX +### Stage 5 - Subscription Auth UX (done) -Goal: make subscription setup clear and observable for operators. +Goal: make subscription setup completable and observable WITHOUT host shell +access. -Scope: +Delivered: -- Add startup checks for `CODEX_HOME/auth.json` when subscription mode is - required. -- Add a clear error message with `codex login --device-auth` instructions. -- Optionally add a health/log command that reports auth mode without exposing - secrets. +- `core/codexauth` drives `codex login --device-auth`, parsing the verification + URL and one-time code off the live (ANSI-colored) output while the CLI is still + polling, and relaying them into the chat. +- `/login` (reserved command, both adapters) starts, re-shows, reports, cancels, + or forces the sign-in. +- Startup no longer fails when only the interactive login is missing + (`airunner.BuildWithPendingLogin`); runs are blocked with an actionable notice + until the login lands, re-checked from `auth.json` on every message so no + restart is needed. Acceptance: -- Missing subscription auth fails with actionable instructions. -- Present `auth.json` allows startup. +- Missing subscription auth starts the bot and blocks runs with instructions. +- Present `auth.json` allows runs immediately. - `CODEX_ACCESS_TOKEN` path logs only that access-token auth is configured, never the token. @@ -553,16 +562,27 @@ Acceptance: ### Subscription Setup 1. Deploy with `AI_BACKEND=codex` and `CODEX_AUTH_MODE=subscription`. -2. Start the container once. -3. Run: - - ```bash - docker exec -it codex login --device-auth - ``` - -4. Complete browser/device login. -5. Restart the bot. -6. Send a small prompt in a private allowed chat. +2. Start the container. With no persisted `auth.json` and no `CODEX_ACCESS_TOKEN` + the bot comes up in a "needs login" state: it logs a warning, serves `/login`, + and answers any other message with a notice pointing at it. +3. From an allow-listed chat, send `/login`. The bot replies with a verification + link and a one-time code. +4. Open the link, sign in, and enter the code. The bot confirms in the chat. +5. Send a small prompt in a private allowed chat — no restart needed. + +`/login status` reports the auth state, `/login cancel` aborts a pending sign-in, +and `/login force` re-authenticates an already-authorized deployment. + +Why device code and not the shell recipe this plan originally carried +(`docker exec -it codex login --device-auth`): that still works, +but it needs shell access to the host, and the plain `codex login` an operator +reaches for first cannot work in a container at all. Plain `codex login` starts a +loopback OAuth server on `http://localhost:1455` and prints an authorize URL whose +`redirect_uri` points back at that loopback — opened on a laptop, the callback +lands on the laptop's own localhost, so the CLI waits forever for a callback that +never arrives, having printed no code. `codex login --device-auth` instead prints +a verification URL plus a one-time code and polls, which is what `core/codexauth` +drives and relays into the chat. ### Billing Setup diff --git a/internal/airunner/backend.go b/internal/airunner/backend.go index 1460ac6..5a9f39e 100644 --- a/internal/airunner/backend.go +++ b/internal/airunner/backend.go @@ -10,6 +10,7 @@ import ( "github.com/duckbugio/flock/core/agent" "github.com/duckbugio/flock/core/claude" "github.com/duckbugio/flock/core/codex" + "github.com/duckbugio/flock/core/codexauth" "github.com/duckbugio/flock/core/openaicompat" "github.com/duckbugio/flock/internal/config" ) @@ -84,6 +85,56 @@ func (r Registry) Build(cfg config.Config) (agent.Runner, agent.Options, agent.P }, nil } +// BuildWithPendingLogin is Build, except that the one provider error an END USER +// can clear from chat — Codex subscription mode with no persisted login yet — is +// not fatal. It rebuilds with the auth-presence check relaxed and reports +// pendingLogin=true. +// +// Without this the deployment deadlocks: startup refuses to run until someone has +// logged in, and the /login flow that performs the login only exists inside the +// running bot. Starting in a "needs login" state breaks the cycle — the bot comes +// up, serves /login, and blocks runs (see codexauth.Manager.BlockedNotice) until +// the login lands. Every other validation error is still fatal, so a misconfigured +// deploy (an API key in subscription mode, billing without acknowledgement) fails +// exactly as loudly as before. +func BuildWithPendingLogin(cfg config.Config) ( + agent.Runner, agent.Options, agent.ProviderInfo, bool, error, +) { + runner, opts, info, err := Build(cfg) + if err == nil || !IsPendingLogin(err) { + return runner, opts, info, false, err + } + relaxed := cfg + relaxed.CodexRequireAuth = false + runner, opts, info, err = Build(relaxed) + if err != nil { + return nil, agent.Options{}, agent.ProviderInfo{}, false, err + } + return runner, opts, info, true, nil +} + +// IsPendingLogin reports whether err merely means "nobody has completed the +// interactive Codex login yet", as opposed to a real misconfiguration. +func IsPendingLogin(err error) bool { + return errors.Is(err, config.ErrCodexSubscriptionAuthRequired) +} + +// CodexAuthConfig derives the /login driver's configuration from the deployment +// config and the resolved provider. It passes the SAME child environment the +// runner uses (CodexEnv), so the login writes auth.json into exactly the +// CODEX_HOME later runs read. +func CodexAuthConfig(cfg config.Config, info agent.ProviderInfo) codexauth.Config { + return codexauth.Config{ + Backend: info.Name, + AuthMode: cfg.CodexAuthModeName(), + Bin: cfg.CodexBin, + Home: cfg.CodexHome, + Env: CodexEnv(cfg), + HasAccessToken: strings.TrimSpace(cfg.CodexAccessToken) != "", + RequireAuth: cfg.CodexRequireAuth, + } +} + // Provider resolves a configured provider name or alias. func (r Registry) Provider(name string) (Provider, error) { normalized := normalizeProviderName(name) diff --git a/internal/airunner/backend_test.go b/internal/airunner/backend_test.go index e501a99..456e0da 100644 --- a/internal/airunner/backend_test.go +++ b/internal/airunner/backend_test.go @@ -3,8 +3,13 @@ package airunner import ( "errors" + "os" + "path/filepath" + "strings" "testing" + "github.com/duckbugio/flock/core/agent" + "github.com/duckbugio/flock/internal/config" ) @@ -136,3 +141,127 @@ func TestBuildOpenAICompatibleProvider(t *testing.T) { t.Fatalf("opts.Model = %q, want qwen-plus", opts.Model) } } + +// TestBuildWithPendingLoginStartsUnauthorized: a Codex subscription deploy that +// has never been signed in must still BOOT, flagged pendingLogin, because the +// /login command that performs the sign-in only exists inside the running bot. +// Failing startup here is what made headless Codex setup impossible. +func TestBuildWithPendingLoginStartsUnauthorized(t *testing.T) { + cfg := config.Config{ + AIBackend: config.AIBackendCodex, + CodexBin: "codex", + CodexAuthMode: config.CodexAuthSubscription, + CodexRequireAuth: true, + CodexHome: t.TempDir(), // no auth.json inside + } + if _, _, _, err := Build(cfg); !errors.Is(err, config.ErrCodexSubscriptionAuthRequired) { + t.Fatalf("Build() error = %v, want ErrCodexSubscriptionAuthRequired", err) + } + + runner, _, info, pendingLogin, err := BuildWithPendingLogin(cfg) + if err != nil { + t.Fatalf("BuildWithPendingLogin() error = %v, want nil", err) + } + if !pendingLogin { + t.Error("pendingLogin = false, want true for a never-signed-in deploy") + } + if runner == nil { + t.Error("runner = nil; the bot must come up to serve /login") + } + if info.Name != config.AIBackendCodex { + t.Errorf("provider = %q, want codex", info.Name) + } +} + +// TestBuildWithPendingLoginKeepsRealErrorsFatal: only the "nobody has logged in +// yet" case is recoverable from chat. A misconfiguration — an API key in +// subscription mode, billing without acknowledgement — must still refuse to boot. +func TestBuildWithPendingLoginKeepsRealErrorsFatal(t *testing.T) { + tests := []struct { + name string + cfg config.Config + want error + }{ + { + name: "api key in subscription mode", + cfg: config.Config{ + AIBackend: config.AIBackendCodex, CodexAuthMode: config.CodexAuthSubscription, + CodexAPIKey: "sk-live", CodexRequireAuth: true, + }, + want: config.ErrCodexSubscriptionWithAPIKey, + }, + { + name: "billing without acknowledgement", + cfg: config.Config{ + AIBackend: config.AIBackendCodex, CodexAuthMode: config.CodexAuthBilling, + CodexAPIKey: "sk-live", CodexBillingAck: false, + }, + want: config.ErrCodexBillingAckRequired, + }, + { + name: "unknown auth mode", + cfg: config.Config{ + AIBackend: config.AIBackendCodex, CodexAuthMode: "whatever", + }, + want: config.ErrCodexUnknownAuthMode, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, _, pendingLogin, err := BuildWithPendingLogin(tt.cfg) + if !errors.Is(err, tt.want) { + t.Fatalf("error = %v, want %v", err, tt.want) + } + if pendingLogin { + t.Error("pendingLogin = true for a misconfiguration") + } + }) + } +} + +// TestBuildWithPendingLoginAuthorizedDeploy: with a persisted login nothing is +// pending and the build is the plain one. +func TestBuildWithPendingLoginAuthorizedDeploy(t *testing.T) { + home := t.TempDir() + if err := os.WriteFile(filepath.Join(home, "auth.json"), []byte("{}"), 0o600); err != nil { + t.Fatalf("write auth.json: %v", err) + } + _, _, _, pendingLogin, err := BuildWithPendingLogin(config.Config{ + AIBackend: config.AIBackendCodex, CodexAuthMode: config.CodexAuthSubscription, + CodexRequireAuth: true, CodexHome: home, + }) + if err != nil { + t.Fatalf("BuildWithPendingLogin() error = %v", err) + } + if pendingLogin { + t.Error("pendingLogin = true for an authorized deploy") + } +} + +// TestCodexAuthConfigMirrorsTheRunner: the login must run with the SAME child +// environment and CODEX_HOME the runner uses, or it would persist auth.json +// where later runs never look. Subscription mode also keeps CODEX_API_KEY out. +func TestCodexAuthConfigMirrorsTheRunner(t *testing.T) { + t.Setenv("CODEX_API_KEY", "sk-should-not-leak") + home := t.TempDir() + cfg := config.Config{ + AIBackend: config.AIBackendCodex, CodexAuthMode: config.CodexAuthSubscription, + CodexBin: "/usr/local/bin/codex", CodexHome: home, CodexAccessToken: "tok", + } + got := CodexAuthConfig(cfg, agent.ProviderInfo{Name: config.AIBackendCodex}) + + if got.Backend != config.AIBackendCodex || got.AuthMode != config.CodexAuthSubscription { + t.Errorf("backend/auth mode = %q/%q, want codex/subscription", got.Backend, got.AuthMode) + } + if got.Bin != "/usr/local/bin/codex" || got.Home != home { + t.Errorf("bin/home = %q/%q, want the configured pair", got.Bin, got.Home) + } + if !got.HasAccessToken { + t.Error("HasAccessToken = false with CODEX_ACCESS_TOKEN set") + } + for _, kv := range got.Env { + if strings.HasPrefix(kv, "CODEX_API_KEY=") { + t.Fatal("CODEX_API_KEY leaked into the subscription-mode login environment") + } + } +} From 97b4ea62fc6b0468d05ad6865b260b5aa7f09dec Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 12:05:51 +0300 Subject: [PATCH 02/21] style(codex): clear the lint gate for the /login sign-in flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran the repo's own golangci-lint (v2.11.4, matching tools/Dockerfile) locally instead of leaving it to CI. Eight findings, all in the new code: - gocyclo: the /login wiring pushed flock-telegram's run() to complexity 31. Extracted buildProvider, which is a cohesive unit anyway — resolve the provider, build the sign-in manager, log what came up. - staticcheck QF1012: fmt.Fprintf over WriteString(fmt.Sprintf(...)). - revive: newTail's parameter shadowed the builtin max; renamed to limit. - mnd: named the scanner's buffer sizes (initScanBuf, maxScanLine). - testpackage: the codexauth tests are whitebox by intent (they drive the parser and manager internals); annotated like core/goal and core/codex. - gosec G304: the run-count probe reads a test-owned temp path. - dogsled/unparam in the new tests. The unparam fix improved a test rather than working around the linter: the post-login message is now a DISTINCT prompt, so the assertion cannot pass on the blocked one arriving late. golangci-lint run: 0 issues. Build, vet, full test suite and -race on the touched packages are green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- cmd/flock-telegram/codex_login_test.go | 38 ++++++++++------ cmd/flock-telegram/main.go | 63 ++++++++++++++++---------- core/codexauth/codexauth.go | 2 +- core/codexauth/codexauth_test.go | 2 + core/codexauth/login.go | 11 ++++- core/codexauth/login_test.go | 1 + internal/airunner/backend_test.go | 6 ++- 7 files changed, 79 insertions(+), 44 deletions(-) diff --git a/cmd/flock-telegram/codex_login_test.go b/cmd/flock-telegram/codex_login_test.go index f86d88f..f53ae3c 100644 --- a/cmd/flock-telegram/codex_login_test.go +++ b/cmd/flock-telegram/codex_login_test.go @@ -29,13 +29,19 @@ func unauthorizedCodexAuth(t *testing.T) (*codexauth.Manager, string) { }), home } -// textMessage builds a plain private-chat text message from userID in chatID. -func textMessage(userID, chatID int64, text string) *models.Message { +// The allowed sender and the private chat every case in this file uses. +const ( + loginTestUserID int64 = 42 + loginTestChatID int64 = 200 +) + +// textMessage builds a plain private-chat text message from the allowed sender. +func textMessage(text string) *models.Message { return &models.Message{ ID: 1, Text: text, - From: &models.User{ID: userID}, - Chat: models.Chat{ID: chatID, Type: models.ChatTypePrivate}, + From: &models.User{ID: loginTestUserID}, + Chat: models.Chat{ID: loginTestChatID, Type: models.ChatTypePrivate}, } } @@ -60,10 +66,10 @@ func quietBot(t *testing.T) *bot.Bot { func TestHandleMessageBlockedWhileCodexUnauthorized(t *testing.T) { auth, _ := unauthorizedCodexAuth(t) svc := &recordingSubmitter{} - cfg := config.Config{AllowedUsers: []int64{42}} + cfg := config.Config{AllowedUsers: []int64{loginTestUserID}} deps := messageDeps{cfg: cfg, service: svc, auth: auth} - handleMessage(context.Background(), deps, quietBot(t), textMessage(42, 200, "build it"), false) + handleMessage(context.Background(), deps, quietBot(t), textMessage("build it"), false) if got := svc.seen(); len(got) != 0 { t.Errorf("submitted %v while Codex was unauthorized, want nothing", got) @@ -76,11 +82,11 @@ func TestHandleMessageBlockedWhileCodexUnauthorized(t *testing.T) { func TestHandleMessageUnblocksAfterLogin(t *testing.T) { auth, home := unauthorizedCodexAuth(t) svc := &recordingSubmitter{} - cfg := config.Config{AllowedUsers: []int64{42}} + cfg := config.Config{AllowedUsers: []int64{loginTestUserID}} deps := messageDeps{cfg: cfg, service: svc, auth: auth} b := quietBot(t) - handleMessage(context.Background(), deps, b, textMessage(42, 200, "build it"), false) + handleMessage(context.Background(), deps, b, textMessage("build it"), false) if got := svc.seen(); len(got) != 0 { t.Fatalf("submitted %v before the login, want nothing", got) } @@ -89,9 +95,11 @@ func TestHandleMessageUnblocksAfterLogin(t *testing.T) { t.Fatalf("write auth.json: %v", err) } - handleMessage(context.Background(), deps, b, textMessage(42, 200, "build it"), false) - if got := svc.seen(); len(got) != 1 || got[0] != "build it" { - t.Errorf("submitted %v after the login, want ['build it']", got) + // A distinct prompt, so the assertion cannot pass on the blocked one leaking + // through late. + handleMessage(context.Background(), deps, b, textMessage("ship it"), false) + if got := svc.seen(); len(got) != 1 || got[0] != "ship it" { + t.Errorf("submitted %v after the login, want ['ship it']", got) } } @@ -99,10 +107,10 @@ func TestHandleMessageUnblocksAfterLogin(t *testing.T) { // which must never block a message. func TestHandleMessageWithoutCodexAuthManager(t *testing.T) { svc := &recordingSubmitter{} - cfg := config.Config{AllowedUsers: []int64{42}} + cfg := config.Config{AllowedUsers: []int64{loginTestUserID}} deps := messageDeps{cfg: cfg, service: svc} - handleMessage(context.Background(), deps, quietBot(t), textMessage(42, 200, "build it"), false) + handleMessage(context.Background(), deps, quietBot(t), textMessage("build it"), false) if got := svc.seen(); len(got) != 1 { t.Errorf("submitted %v with no auth manager, want the message through", got) @@ -115,7 +123,7 @@ func TestHandleMessageWithoutCodexAuthManager(t *testing.T) { func TestLoginCommandIsRoutedToItsHandler(t *testing.T) { auth, _ := unauthorizedCodexAuth(t) svc := &recordingSubmitter{} - cfg := config.Config{AllowedUsers: []int64{42}} + cfg := config.Config{AllowedUsers: []int64{loginTestUserID}} defaultHandler := func(ctx context.Context, b *bot.Bot, update *models.Update) { if msg := update.Message; msg != nil { @@ -135,7 +143,7 @@ func TestLoginCommandIsRoutedToItsHandler(t *testing.T) { b.RegisterHandlerMatchFunc(commandMatch(name), h) } - b.ProcessUpdate(context.Background(), privateCommandUpdate(42, 200, "/login status", len("/login"))) + b.ProcessUpdate(context.Background(), privateCommandUpdate(loginTestUserID, loginTestChatID, "/login status", len("/login"))) if got := svc.seen(); len(got) != 0 { t.Errorf("/login leaked to the model as %v, want it handled by the bot", got) diff --git a/cmd/flock-telegram/main.go b/cmd/flock-telegram/main.go index e8e991a..db5f68a 100644 --- a/cmd/flock-telegram/main.go +++ b/cmd/flock-telegram/main.go @@ -58,33 +58,22 @@ func main() { os.Exit(run()) } -// run holds the adapter's startup and serve logic, returning a process exit code. -// Splitting it out of main lets deferred cleanups (signal context, dispatcher -// drain) run before the process exits, which a direct os.Exit in main would skip. -func run() int { - cfg, err := config.Load() - if err != nil { - slog.Error("load config", "error", err) - return 1 - } - if err := cfg.ValidateTelegram(); err != nil { - slog.Error("invalid telegram config", "error", err) - return 1 - } - - logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - Level: cfg.SlogLevel(), - })) - slog.SetDefault(logger) - - // A Codex subscription deploy that has never been signed in starts anyway, in a - // "needs login" state: the /login command is the ONLY way to complete that - // sign-in headlessly, so refusing to boot would make it unreachable. Runs stay - // blocked (with an actionable notice) until the login lands. +// buildProvider resolves the configured AI provider, its run defaults, and the +// Codex sign-in manager that serves /login, logging what came up. +// +// A Codex subscription deploy that has never been signed in is NOT a startup +// failure: /login is the only way to complete that sign-in headlessly, so +// refusing to boot would make it unreachable. Such a deploy comes up flagged, +// with runs blocked (by an actionable notice) until the login lands. Every other +// provider misconfiguration is still fatal. The error is logged here; the caller +// only needs to know to exit. +func buildProvider(cfg config.Config, logger *slog.Logger) ( + agent.Runner, agent.Options, agent.ProviderInfo, *codexauth.Manager, error, +) { runner, opts, provider, pendingLogin, err := airunner.BuildWithPendingLogin(cfg) if err != nil { logger.Error("invalid ai provider config", "provider", cfg.AIBackend, "error", err) - return 1 + return nil, agent.Options{}, agent.ProviderInfo{}, nil, err } auth := codexauth.NewManager(airunner.CodexAuthConfig(cfg, provider)) if pendingLogin { @@ -110,6 +99,32 @@ func run() int { "capabilities", provider.Capabilities, ) } + return runner, opts, provider, auth, nil +} + +// run holds the adapter's startup and serve logic, returning a process exit code. +// Splitting it out of main lets deferred cleanups (signal context, dispatcher +// drain) run before the process exits, which a direct os.Exit in main would skip. +func run() int { + cfg, err := config.Load() + if err != nil { + slog.Error("load config", "error", err) + return 1 + } + if err := cfg.ValidateTelegram(); err != nil { + slog.Error("invalid telegram config", "error", err) + return 1 + } + + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: cfg.SlogLevel(), + })) + slog.SetDefault(logger) + + runner, opts, provider, auth, err := buildProvider(cfg, logger) + if err != nil { + return 1 + } ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() diff --git a/core/codexauth/codexauth.go b/core/codexauth/codexauth.go index 81a360d..da742b5 100644 --- a/core/codexauth/codexauth.go +++ b/core/codexauth/codexauth.go @@ -378,7 +378,7 @@ func promptText(p Prompt) string { _, _ = b.WriteString("\n\n2. Enter this one-time code there:\n") _, _ = b.WriteString(p.Code) if p.ExpiresIn > 0 { - _, _ = b.WriteString(fmt.Sprintf("\n\nThe code expires in %s.", humanDuration(p.ExpiresIn))) + _, _ = fmt.Fprintf(&b, "\n\nThe code expires in %s.", humanDuration(p.ExpiresIn)) } _, _ = b.WriteString("\n\nI'll confirm here as soon as it goes through. /login cancel aborts it.") return b.String() diff --git a/core/codexauth/codexauth_test.go b/core/codexauth/codexauth_test.go index 9618f70..51c23c1 100644 --- a/core/codexauth/codexauth_test.go +++ b/core/codexauth/codexauth_test.go @@ -1,3 +1,4 @@ +//nolint:testpackage // intentionally whitebox to test the device-login parser and manager internals package codexauth import ( @@ -244,6 +245,7 @@ func TestDispatchReshowsPendingPrompt(t *testing.T) { t.Errorf("second /login = %q, want it to say a login is already pending", again) } + //nolint:gosec // the path is a test-owned temp file data, err := os.ReadFile(runs) if err != nil { t.Fatalf("read run log: %v", err) diff --git a/core/codexauth/login.go b/core/codexauth/login.go index 7aa3163..5b92756 100644 --- a/core/codexauth/login.go +++ b/core/codexauth/login.go @@ -46,6 +46,13 @@ const killGrace = 500 * time.Millisecond // outputTailBytes is how much CLI output we keep to enrich a failure message. const outputTailBytes = 4096 +// initScanBuf is the line scanner's initial buffer size, and maxScanLine caps a +// single output line so a pathological stream cannot grow it without bound. +const ( + initScanBuf = 4096 + maxScanLine = 1 << 20 +) + // ErrDeviceAuthUnsupported reports a Codex CLI too old to know --device-auth. // Without it there is no headless login path at all, so the operator has to // upgrade the CLI rather than retry. @@ -223,7 +230,7 @@ func condense(output string) string { // It returns when rd hits EOF. func scanLines(rd io.Reader, onLine func(string)) { sc := bufio.NewScanner(rd) - sc.Buffer(make([]byte, 0, 4096), 1<<20) + sc.Buffer(make([]byte, 0, initScanBuf), maxScanLine) sc.Split(splitLines) for sc.Scan() { line := stripANSI(sc.Text()) @@ -365,7 +372,7 @@ type tail struct { buf []byte } -func newTail(max int) *tail { return &tail{max: max} } +func newTail(limit int) *tail { return &tail{max: limit} } // WriteString appends s, dropping the oldest bytes past the cap. func (t *tail) WriteString(s string) { diff --git a/core/codexauth/login_test.go b/core/codexauth/login_test.go index 3816d99..17d6dd7 100644 --- a/core/codexauth/login_test.go +++ b/core/codexauth/login_test.go @@ -1,3 +1,4 @@ +//nolint:testpackage // intentionally whitebox to test the device-login parser and manager internals package codexauth import ( diff --git a/internal/airunner/backend_test.go b/internal/airunner/backend_test.go index 456e0da..74eeca2 100644 --- a/internal/airunner/backend_test.go +++ b/internal/airunner/backend_test.go @@ -9,7 +9,6 @@ import ( "testing" "github.com/duckbugio/flock/core/agent" - "github.com/duckbugio/flock/internal/config" ) @@ -226,7 +225,7 @@ func TestBuildWithPendingLoginAuthorizedDeploy(t *testing.T) { if err := os.WriteFile(filepath.Join(home, "auth.json"), []byte("{}"), 0o600); err != nil { t.Fatalf("write auth.json: %v", err) } - _, _, _, pendingLogin, err := BuildWithPendingLogin(config.Config{ + runner, _, _, pendingLogin, err := BuildWithPendingLogin(config.Config{ AIBackend: config.AIBackendCodex, CodexAuthMode: config.CodexAuthSubscription, CodexRequireAuth: true, CodexHome: home, }) @@ -236,6 +235,9 @@ func TestBuildWithPendingLoginAuthorizedDeploy(t *testing.T) { if pendingLogin { t.Error("pendingLogin = true for an authorized deploy") } + if runner == nil { + t.Error("runner = nil for an authorized deploy") + } } // TestCodexAuthConfigMirrorsTheRunner: the login must run with the SAME child From 0ee85d20dda9263e763a3d3818dae3bb72348169 Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 12:32:48 +0300 Subject: [PATCH 03/21] fix(codex): close the auth gate for every run source and keep the code private MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review on #66. MAJOR — background runs bypassed the gate. Blocking only in the adapters left four of the five ways a run starts wide open: a poller relay, a cron fire, a workspace follow-up and the restart replay never touch an adapter, so an unauthorized deploy marched them into the CLI once per tick with nothing user-visible. The gate now lives in core/chat (RunGate), at the single point every run passes through. The chat is told once per unauthorized period rather than once per cron tick, and a blocked replay KEEPS its pending marker, so an interrupted run is not lost to the outage that stopped it. The adapters keep an early check so an unauthorized deploy never pays for a transcription or a download. MAJOR — /login leaked the one-time code into group chats. The reply goes to the chat, not the sender, so every member could read the link and the code — and whoever acted on it first would bind THEIR ChatGPT account to the bot, routing every later run and its history through that account. Starting a sign-in is now refused outside a direct message in both adapters; status and cancel stay available everywhere, since neither reveals a code. Also from the review: - The prompt parser could pair a stray URL from stderr with the real code from stdout — two reader goroutines, nondeterministic order — sending the user to the wrong page holding a valid code. Each stream now parses independently and a prompt is released only when the URL and code came from the SAME one. Within a stream, candidates are ranked (device path > known host > anything else), so an upgrade notice printed first cannot win; a lone unrecognized URL is still used, so a changed domain degrades instead of breaking. - Only the chat that started a login learned the outcome. Dispatch now takes a Subscriber, so a second /login both re-shows the code and receives the verdict; subscriptions are keyed by destination, so one chat asking twice is not told everything twice. - A pending login is cancelled on shutdown; it was detached by design, so nothing else would have ended it and the polling Codex child would have been orphaned. - Startup logged authorized=true with CODEX_REQUIRE_AUTH=false and no credential at all. It now logs credentials_present and require_auth beside it, and CredentialsPresent is exported as the factual answer (Authorized stays the policy one). /login itself keys off the factual answer, so it stays usable on exactly those deploys. - The duplicated startup wiring moved into airunner.BuildProvider, called by both binaries. - The VK test reached past ReceiverConfig into the private field, leaving NewReceiver's own plumbing uncovered; it now wires through the config. - Help text no longer promises "authorize the AI provider" on backends that need no login. Not changed, with reasons: /login in VK is already allow-list gated — onMessageNew returns for a disallowed sender before any reserved command is dispatched (now pinned by a test). And a present auth.json is not proof the credential still works; re-validating would mean spawning `codex login status` on every message, so /login force remains the remedy for a stale token (documented on the method). golangci-lint: 0 issues. Build, vet, full suite and -race all green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/telegram/.env.example | 3 +- adapters/telegram/commands.go | 2 +- adapters/vk/commands.go | 2 +- adapters/vk/receiver.go | 59 +++++--- adapters/vk/receiver_test.go | 75 +++++++++- cmd/duck-vk/main.go | 44 ++---- cmd/flock-telegram/codex_login_test.go | 122 ++++++++++++++++ cmd/flock-telegram/main.go | 110 +++++++------- core/chat/reserved.go | 2 +- core/chat/rungate.go | 56 ++++++++ core/chat/rungate_test.go | 191 +++++++++++++++++++++++++ core/chat/service.go | 22 ++- core/codexauth/codexauth.go | 125 ++++++++++++---- core/codexauth/codexauth_test.go | 107 ++++++++++++-- core/codexauth/login.go | 77 ++++++++-- core/codexauth/login_test.go | 88 +++++++++++- docs/codex-integration-plan.md | 15 +- internal/airunner/backend.go | 53 +++++++ 18 files changed, 967 insertions(+), 186 deletions(-) create mode 100644 core/chat/rungate.go create mode 100644 core/chat/rungate_test.go diff --git a/adapters/telegram/.env.example b/adapters/telegram/.env.example index 1f50a98..b04c42b 100644 --- a/adapters/telegram/.env.example +++ b/adapters/telegram/.env.example @@ -22,7 +22,8 @@ AI_BACKEND=claude # claude | codex | openai-compatible # subscription deploy cannot accidentally switch to usage-based API billing. # # First-time sign-in needs no shell on the host: the bot STARTS unauthorized and -# an allow-listed user sends /login in the chat. The bot replies with a +# an allow-listed user sends /login in a DIRECT MESSAGE (the reply carries a +# one-time code, so it is refused in group chats). The bot replies with a # verification link and a one-time code (this is `codex login --device-auth`; # plain `codex login` would open a loopback server on localhost:1455 that nothing # outside the container can reach, which is why it appears to hang). Open the diff --git a/adapters/telegram/commands.go b/adapters/telegram/commands.go index 5707e6c..0915451 100644 --- a/adapters/telegram/commands.go +++ b/adapters/telegram/commands.go @@ -93,7 +93,7 @@ const HelpText = "Flock Telegram assistant — available commands:\n\n" + "/schedule — manage scheduled jobs (when enabled)\n" + "/goal — arm a goal an independent evaluator re-checks after every run " + "(/goal off to disarm)\n" + - "/login — authorize the AI provider (Codex browser sign-in; /login status, /login cancel)\n\n" + + "/login — sign in to Codex on a subscription (not needed on other backends; /login status, /login cancel)\n\n" + "Send any other message to run it through the assistant." // WelcomeText is the static usage message replied to an allowed user who sends diff --git a/adapters/vk/commands.go b/adapters/vk/commands.go index 4bb52fd..a25d579 100644 --- a/adapters/vk/commands.go +++ b/adapters/vk/commands.go @@ -11,7 +11,7 @@ const HelpText = "Flock VK assistant — available commands:\n\n" + "/schedule — manage scheduled jobs (when enabled)\n" + "/goal — arm a goal an independent evaluator re-checks after every run " + "(/goal off to disarm)\n" + - "/login — authorize the AI provider (Codex browser sign-in; /login status, /login cancel)\n\n" + + "/login — sign in to Codex on a subscription (not needed on other backends; /login status, /login cancel)\n\n" + "Send any other message to run it through the assistant." // goalUsageText is the /goal usage reply, mirroring the Telegram adapter. diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index 937ca60..00d45a6 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -340,16 +340,6 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { return } - // Codex with no completed sign-in cannot run anything. Say so once, after the - // gate (so an unaddressed community message stays silent) and before any paid - // work (transcription, downloads) or a run that would only fail inside the CLI. - // /login is dispatched above and stays reachable while this gate is closed. - if notice, blocked := r.auth.BlockedNotice(); blocked { - r.logger.Debug("vk: codex unauthorized — blocking run", "peer_id", peerID) - r.notify(ctx, peerID, notice) - return - } - if r.guards != nil { if allow, reason := r.guards(msg.FromID); !allow { r.logger.Debug("vk: guardrail denied message", "peer_id", peerID, "user_id", msg.FromID, "reason", reason) @@ -358,6 +348,19 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { } } + // Codex with no completed sign-in cannot run anything. Say so here — after the + // mention gate (so an unaddressed community message stays silent) and after the + // guards (an unauthorized deploy in a busy conversation would otherwise answer + // every single message, unthrottled), but before any paid work: the guards spend + // nothing, while transcription and downloads do. /login is dispatched above and + // stays reachable while this gate is closed. core/chat gates the run itself; + // this is the early, user-facing half. + if notice, blocked := r.auth.BlockedNotice(); blocked { + r.logger.Debug("vk: codex unauthorized — blocking run", "peer_id", peerID) + r.notify(ctx, peerID, notice) + return + } + text := strings.TrimSpace(cleaned) // Voice: transcribe the first audio_message attachment (gate already passed, so @@ -422,22 +425,42 @@ func (r *Receiver) dispatchReserved(ctx context.Context, name string, msg messag } } +// loginGroupChatText refuses to start a sign-in in a community conversation. +const loginGroupChatText = "Send /login in a direct message with me, not in a conversation.\n\n" + + "The sign-in reply carries a one-time code that authorizes an account for the whole bot, " + + "and everyone in this conversation would see it." + // dispatchLogin serves /login: start, report, or cancel the Codex device-code // sign-in. It stays available while the deployment is unauthorized — it is the -// only way out of that state. +// only way out of that state. The sender is already allow-list gated by +// onMessageNew, which returns before any reserved command is dispatched. +// +// Starting a sign-in is refused in a community conversation. The reply goes to +// the PEER, not the sender, so in a conversation the verification link and the +// one-time code would be readable by every participant, allow-listed or not — +// and whoever acts on the code first binds THEIR ChatGPT account to the bot, so +// every later run (and its history) would go through that account. status and +// cancel stay available everywhere: neither reveals a code. // // The sign-in outlives this call: Dispatch replies immediately and keeps working -// in the background, delivering the link, the one-time code, and the verdict -// through notify. That callback builds its OWN context, because this update's is +// in the background, delivering the link, the one-time code, and the verdict to +// its subscribers. The callback builds its OWN context, because this update's is // long gone by the time a user finishes in a browser. func (r *Receiver) dispatchLogin(ctx context.Context, msg messageObject) { peerID := msg.PeerID - notify := func(text string) { - sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginNotifyTimeout) - defer cancel() - r.notify(sendCtx, peerID, text) + if isGroupPeer(peerID) && codexauth.StartsLogin(commandArgs(msg.Text)) { + r.notify(ctx, peerID, loginGroupChatText) + return + } + sub := codexauth.Subscriber{ + ID: chatIDStr(peerID), + Notify: func(text string) { + sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginNotifyTimeout) + defer cancel() + r.notify(sendCtx, peerID, text) + }, } - r.notify(ctx, peerID, r.auth.Dispatch(ctx, commandArgs(msg.Text), notify)) + r.notify(ctx, peerID, r.auth.Dispatch(ctx, commandArgs(msg.Text), sub)) } // dispatchGoal serves /goal: arm, show, or disarm the calling chat's goal via diff --git a/adapters/vk/receiver_test.go b/adapters/vk/receiver_test.go index 6197677..a362a9e 100644 --- a/adapters/vk/receiver_test.go +++ b/adapters/vk/receiver_test.go @@ -118,7 +118,20 @@ func (n *fakeNotice) Notify(_ context.Context, _ int64, text string) { // newTestReceiver builds a Receiver wired to the fakes, allowing user 42, group // 123, with the given requireMention. func newTestReceiver(svc Service, notices NoticeSender, requireMention bool, acked *[]string) *Receiver { + return newTestReceiverOpts(svc, notices, requireMention, acked, nil) +} + +// newTestReceiverWithAuth is newTestReceiver with a Codex sign-in manager wired +// through the config, as production does. +func newTestReceiverWithAuth(svc Service, notices NoticeSender, auth *codexauth.Manager) *Receiver { + return newTestReceiverOpts(svc, notices, false, nil, auth) +} + +func newTestReceiverOpts( + svc Service, notices NoticeSender, requireMention bool, acked *[]string, auth *codexauth.Manager, +) *Receiver { return NewReceiver(ReceiverConfig{ + CodexAuth: auth, Service: svc, GroupID: 123, RequireMention: requireMention, @@ -626,17 +639,17 @@ func isContextErr(err error) bool { } // unauthorizedCodexReceiver wires a receiver whose Codex backend has never been -// signed in (an empty CODEX_HOME, so no auth.json). +// signed in (an empty CODEX_HOME, so no auth.json). The manager goes in through +// ReceiverConfig, the same way production wires it, so this covers NewReceiver's +// own plumbing instead of reaching past it into the private field. func unauthorizedCodexReceiver(t *testing.T, svc Service, notices NoticeSender) *Receiver { t.Helper() - r := newTestReceiver(svc, notices, false, nil) - r.auth = codexauth.NewManager(codexauth.Config{ + return newTestReceiverWithAuth(svc, notices, codexauth.NewManager(codexauth.Config{ Backend: codexauth.BackendCodex, AuthMode: codexauth.AuthSubscription, RequireAuth: true, Home: t.TempDir(), - }) - return r + })) } // TestReceiverBlocksRunsWhileCodexUnauthorized: a Codex deploy with no completed @@ -692,3 +705,55 @@ func TestReceiverLoginWithoutManagerIsInert(t *testing.T) { t.Errorf("Handle calls = %d, want 1 (no manager must never block)", len(svc.handleCalls)) } } + +// TestReceiverLoginRefusedInConversation: the reply goes to the PEER, so in a +// community conversation the verification link and one-time code would be +// readable by every participant — and whoever acts on the code first binds THEIR +// ChatGPT account to the bot, sending every later run through it. +func TestReceiverLoginRefusedInConversation(t *testing.T) { + svc := &fakeService{} + notices := &fakeNotice{} + r := unauthorizedCodexReceiver(t, svc, notices) + + // 2000000000+ is VK's conversation (chat) peer range. + r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 2000000001, Text: "/login"})) + + if len(notices.texts) != 1 || !strings.Contains(notices.texts[0], "direct message") { + t.Fatalf("notices = %v, want the refusal pointing at a direct message", notices.texts) + } + if strings.Contains(notices.texts[0], "http") { + t.Error("the refusal leaked a link into the conversation") + } +} + +// TestReceiverLoginStatusAllowedInConversation: status reveals no code, so it +// stays available everywhere — refusing it would just be noise. +func TestReceiverLoginStatusAllowedInConversation(t *testing.T) { + svc := &fakeService{} + notices := &fakeNotice{} + r := unauthorizedCodexReceiver(t, svc, notices) + + r.dispatch(context.Background(), msgNewUpdate(t, messageObject{ + FromID: 42, PeerID: 2000000001, Text: "/login status", + })) + + if len(notices.texts) != 1 || !strings.Contains(notices.texts[0], "NOT authorized") { + t.Errorf("notices = %v, want the status reply", notices.texts) + } +} + +// TestReceiverLoginRejectsDisallowedSender: /login (and especially /login force) +// changes the provider credentials for the WHOLE process, so it must be as +// allow-list gated as any other command. onMessageNew returns before dispatching +// any reserved command for a disallowed sender; this pins that. +func TestReceiverLoginRejectsDisallowedSender(t *testing.T) { + svc := &fakeService{} + notices := &fakeNotice{} + r := unauthorizedCodexReceiver(t, svc, notices) + + r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 999, PeerID: 999, Text: "/login"})) + + if len(notices.texts) != 0 { + t.Errorf("a disallowed sender got %v, want silence", notices.texts) + } +} diff --git a/cmd/duck-vk/main.go b/cmd/duck-vk/main.go index 7ab5ca3..bcddd8e 100644 --- a/cmd/duck-vk/main.go +++ b/cmd/duck-vk/main.go @@ -27,7 +27,6 @@ import ( "github.com/duckbugio/flock/adapters/vk" "github.com/duckbugio/flock/core/chat" "github.com/duckbugio/flock/core/claude" - "github.com/duckbugio/flock/core/codexauth" "github.com/duckbugio/flock/core/cost" "github.com/duckbugio/flock/core/dispatch" "github.com/duckbugio/flock/core/ghstar" @@ -79,43 +78,19 @@ func run() int { })) slog.SetDefault(logger) - // A Codex subscription deploy that has never been signed in starts anyway, in a - // "needs login" state: /login is the only headless way to complete that sign-in, - // so refusing to boot would make it unreachable. Runs stay blocked (with an - // actionable notice) until the login lands. - runner, opts, provider, pendingLogin, err := airunner.BuildWithPendingLogin(cfg) + runner, opts, provider, auth, err := airunner.BuildProvider(cfg, logger) if err != nil { - logger.Error("invalid ai provider config", "provider", cfg.AIBackend, "error", err) return 1 } - auth := codexauth.NewManager(airunner.CodexAuthConfig(cfg, provider)) - if pendingLogin { - logger.Warn("codex is not authorized yet; runs are blocked until /login completes", - "codex_home", cfg.CodexHome) - } - if provider.Name == config.AIBackendCodex { - logger.Info("codex backend enabled", - "provider", provider.Name, - "display_name", provider.DisplayName, - "auth_mode", cfg.CodexAuthModeName(), - "authorized", auth.Authorized(), - "sandbox", cfg.CodexSandbox, - "approval_policy", cfg.CodexApprovalPolicy, - "codex_home", cfg.CodexHome, - "capabilities", provider.Capabilities, - ) - } else { - logger.Info("ai provider enabled", - "provider", provider.Name, - "display_name", provider.DisplayName, - "model", opts.Model, - "capabilities", provider.Capabilities, - ) - } ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() + // A pending device login is deliberately detached from the update that + // started it, so nothing else would ever end it on shutdown: the polling + // Codex child would outlive this process as an orphan. + defer auth.Cancel() + // Best-effort git startup wiring (identity, default branch, inline credential // helper). Non-fatal: a git setup failure must not crash-loop the bot. if err := gitsetup.Apply(ctx, gitsetup.Config{ @@ -249,7 +224,12 @@ func run() int { Opts: opts, Timeout: cfg.ClaudeTimeout(), RetryAfter: vk.RetryAfter, - Logger: logger, + // The provider gate at the single point every run passes through — a user + // message, a cron fire, a workspace follow-up, a poller relay, the restart + // replay. The adapters block the message path early (before paid work); this + // is what stops the other four from marching into an unauthenticated CLI. + Auth: auth, + Logger: logger, }) // One-shot follow-ups (the workspace followup/.md convention). diff --git a/cmd/flock-telegram/codex_login_test.go b/cmd/flock-telegram/codex_login_test.go index f53ae3c..da0ccaf 100644 --- a/cmd/flock-telegram/codex_login_test.go +++ b/cmd/flock-telegram/codex_login_test.go @@ -2,8 +2,12 @@ package main import ( "context" + "io" + "net/http" "os" "path/filepath" + "strings" + "sync" "testing" "time" @@ -157,3 +161,121 @@ func TestLoginIsAReservedCommand(t *testing.T) { t.Fatal("login is not a reserved command; it would be forwarded to the model") } } + +// groupCommandUpdate builds a GROUP-chat slash command from the allowed sender. +func groupCommandUpdate(text string, cmdLen int) *models.Update { + return &models.Update{Message: &models.Message{ + ID: 1, + Text: text, + From: &models.User{ID: loginTestUserID}, + Chat: models.Chat{ID: loginTestChatID, Type: models.ChatTypeGroup}, + Entities: []models.MessageEntity{{Type: models.MessageEntityTypeBotCommand, Offset: 0, Length: cmdLen}}, + }} +} + +// TestLoginRefusedInGroupChat: the reply goes to the CHAT, so in a group the +// verification link and one-time code would be readable by every member, +// allow-listed or not — and whoever acts on the code first binds THEIR ChatGPT +// account to the bot. The refusal must happen before any CLI is spawned. +func TestLoginRefusedInGroupChat(t *testing.T) { + // A deliberately unusable CODEX_BIN: if the group check failed to short-circuit, + // the manager would try to run it and the test's assertions would still pass — + // so the manager is also asserted to have started nothing. + home := t.TempDir() + auth := codexauth.NewManager(codexauth.Config{ + Backend: codexauth.BackendCodex, + AuthMode: codexauth.AuthSubscription, + RequireAuth: true, + Home: home, + Bin: filepath.Join(home, "no-such-codex"), + }) + cfg := config.Config{AllowedUsers: []int64{loginTestUserID}} + + replies := &capturingHTTPClient{} + b, err := bot.New("123456:test-token", + bot.WithSkipGetMe(), + bot.WithNotAsyncHandlers(), + bot.WithHTTPClient(time.Minute, replies), + ) + if err != nil { + t.Fatalf("build test bot: %v", err) + } + for name, h := range reservedHandlers(cfg, nil, nil, auth) { + b.RegisterHandlerMatchFunc(commandMatch(name), h) + } + + b.ProcessUpdate(context.Background(), groupCommandUpdate("/login", len("/login"))) + + got := replies.seen() + if len(got) != 1 { + t.Fatalf("replies = %v, want exactly one", got) + } + if !strings.Contains(got[0], "direct message") { + t.Errorf("reply = %q, want the direct-message refusal", got[0]) + } + if strings.Contains(got[0], "http") { + t.Errorf("reply = %q leaked a link into the group", got[0]) + } + if auth.StatusText() != "Codex is NOT authorized. Send /login to sign in with your ChatGPT account." { + t.Errorf("a sign-in was started from a group chat: %q", auth.StatusText()) + } +} + +// capturingHTTPClient answers every Bot API call locally like stubHTTPClient, +// but records the raw payload of each sendMessage so a test can assert what the +// user was actually told (the encoding varies, so assertions substring-match). +type capturingHTTPClient struct { + mu sync.Mutex + texts []string +} + +func (c *capturingHTTPClient) Do(req *http.Request) (*http.Response, error) { + if req.Body != nil && strings.Contains(req.URL.Path, "sendMessage") { + body, _ := io.ReadAll(req.Body) + c.mu.Lock() + c.texts = append(c.texts, string(body)) + c.mu.Unlock() + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":1}}`)), + Header: make(http.Header), + }, nil +} + +func (c *capturingHTTPClient) seen() []string { + c.mu.Lock() + defer c.mu.Unlock() + return append([]string(nil), c.texts...) +} + +// TestLoginStatusAllowedInGroupChat: status reveals no code, so refusing it +// everywhere would be noise rather than protection. It must answer with the real +// state, not the direct-message refusal. +func TestLoginStatusAllowedInGroupChat(t *testing.T) { + auth, _ := unauthorizedCodexAuth(t) + cfg := config.Config{AllowedUsers: []int64{loginTestUserID}} + http := &capturingHTTPClient{} + + b, err := bot.New("123456:test-token", + bot.WithSkipGetMe(), + bot.WithNotAsyncHandlers(), + bot.WithHTTPClient(time.Minute, http), + ) + if err != nil { + t.Fatalf("build test bot: %v", err) + } + for name, h := range reservedHandlers(cfg, nil, nil, auth) { + b.RegisterHandlerMatchFunc(commandMatch(name), h) + } + + b.ProcessUpdate(context.Background(), groupCommandUpdate("/login status", len("/login"))) + + got := http.seen() + if len(got) != 1 { + t.Fatalf("replies = %v, want exactly one", got) + } + if !strings.Contains(got[0], "NOT authorized") { + t.Errorf("reply = %q, want the status, not a refusal", got[0]) + } +} diff --git a/cmd/flock-telegram/main.go b/cmd/flock-telegram/main.go index db5f68a..0210fc1 100644 --- a/cmd/flock-telegram/main.go +++ b/cmd/flock-telegram/main.go @@ -58,50 +58,6 @@ func main() { os.Exit(run()) } -// buildProvider resolves the configured AI provider, its run defaults, and the -// Codex sign-in manager that serves /login, logging what came up. -// -// A Codex subscription deploy that has never been signed in is NOT a startup -// failure: /login is the only way to complete that sign-in headlessly, so -// refusing to boot would make it unreachable. Such a deploy comes up flagged, -// with runs blocked (by an actionable notice) until the login lands. Every other -// provider misconfiguration is still fatal. The error is logged here; the caller -// only needs to know to exit. -func buildProvider(cfg config.Config, logger *slog.Logger) ( - agent.Runner, agent.Options, agent.ProviderInfo, *codexauth.Manager, error, -) { - runner, opts, provider, pendingLogin, err := airunner.BuildWithPendingLogin(cfg) - if err != nil { - logger.Error("invalid ai provider config", "provider", cfg.AIBackend, "error", err) - return nil, agent.Options{}, agent.ProviderInfo{}, nil, err - } - auth := codexauth.NewManager(airunner.CodexAuthConfig(cfg, provider)) - if pendingLogin { - logger.Warn("codex is not authorized yet; runs are blocked until /login completes", - "codex_home", cfg.CodexHome) - } - if provider.Name == config.AIBackendCodex { - logger.Info("codex backend enabled", - "provider", provider.Name, - "display_name", provider.DisplayName, - "auth_mode", cfg.CodexAuthModeName(), - "authorized", auth.Authorized(), - "sandbox", cfg.CodexSandbox, - "approval_policy", cfg.CodexApprovalPolicy, - "codex_home", cfg.CodexHome, - "capabilities", provider.Capabilities, - ) - } else { - logger.Info("ai provider enabled", - "provider", provider.Name, - "display_name", provider.DisplayName, - "model", opts.Model, - "capabilities", provider.Capabilities, - ) - } - return runner, opts, provider, auth, nil -} - // run holds the adapter's startup and serve logic, returning a process exit code. // Splitting it out of main lets deferred cleanups (signal context, dispatcher // drain) run before the process exits, which a direct os.Exit in main would skip. @@ -121,7 +77,7 @@ func run() int { })) slog.SetDefault(logger) - runner, opts, provider, auth, err := buildProvider(cfg, logger) + runner, opts, provider, auth, err := airunner.BuildProvider(cfg, logger) if err != nil { return 1 } @@ -129,6 +85,11 @@ func run() int { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() + // A pending device login is deliberately detached from the update that + // started it, so nothing else would ever end it on shutdown: the polling + // Codex child would outlive this process as an orphan. + defer auth.Cancel() + // Best-effort git startup wiring (identity, default branch, and — when a host // + token are set — an inline credential helper that reads the token from the // env at call time). Non-fatal: a git setup failure must not crash-loop the bot. @@ -336,7 +297,12 @@ func run() int { Opts: opts, Timeout: cfg.ClaudeTimeout(), RetryAfter: telegram.RetryAfter, - Logger: logger, + // The provider gate at the single point every run passes through — a user + // message, a cron fire, a workspace follow-up, a poller relay, the restart + // replay. The adapters block the message path early (before paid work); this + // is what stops the other four from marching into an unauthenticated CLI. + Auth: auth, + Logger: logger, }) // Background cron scheduler (OFF by default). When enabled, open the durable @@ -567,17 +533,6 @@ func handleMessage(ctx context.Context, deps messageDeps, b *bot.Bot, msg *model return } - // Codex with no completed sign-in cannot run anything. Say so once, here — after - // the gate (so an unaddressed group message stays silent) but before any paid - // work (transcription, downloads) or a run that would only fail deep inside the - // CLI. /login is a reserved command routed elsewhere, so it stays reachable - // while this gate is closed. - if notice, blocked := deps.auth.BlockedNotice(); blocked { - slog.Debug("codex unauthorized — blocking run", "chat_id", msg.Chat.ID) - sendCommandReply(ctx, b, msg.Chat.ID, notice) - return - } - // Guardrails (plan §9b): once the message is accepted for handling but BEFORE // any paid work (voice transcription or the Claude run), apply the per-user // rate limit and cumulative cost cap. A denied message spends no transcription @@ -590,6 +545,19 @@ func handleMessage(ctx context.Context, deps messageDeps, b *bot.Bot, msg *model return } + // Codex with no completed sign-in cannot run anything. Say so here — after the + // mention gate (so an unaddressed group message stays silent) and after the rate + // limit (an unauthorized deploy in a busy group would otherwise answer every + // single message, unthrottled), but before any paid work: the guards spend + // nothing, while transcription and downloads do. /login is a reserved command + // routed elsewhere, so it stays reachable while this gate is closed. core/chat + // gates the run itself; this is the early, user-facing half. + if notice, blocked := deps.auth.BlockedNotice(); blocked { + slog.Debug("codex unauthorized — blocking run", "chat_id", msg.Chat.ID) + sendCommandReply(ctx, b, msg.Chat.ID, notice) + return + } + text := strings.TrimSpace(cleaned) // Normalize the group form of a forwarded slash command so an unknown command @@ -1043,15 +1011,33 @@ func loginHandler(cfg config.Config, auth *codexauth.Manager) bot.HandlerFunc { if !ok { return } - notify := func(text string) { - sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginNotifyTimeout) - defer cancel() - sendCommandReply(sendCtx, b, chatID, text) + args := commandArgs(update.Message.Text) + if update.Message.Chat.Type != models.ChatTypePrivate && codexauth.StartsLogin(args) { + sendCommandReply(ctx, b, chatID, loginGroupChatText) + return + } + sub := codexauth.Subscriber{ + ID: chatIDStr(chatID), + Notify: func(text string) { + sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginNotifyTimeout) + defer cancel() + sendCommandReply(sendCtx, b, chatID, text) + }, } - sendCommandReply(ctx, b, chatID, auth.Dispatch(ctx, commandArgs(update.Message.Text), notify)) + sendCommandReply(ctx, b, chatID, auth.Dispatch(ctx, args, sub)) } } +// loginGroupChatText refuses to start a sign-in in a group chat. The reply goes +// to the CHAT, not the sender, so in a group the verification link and one-time +// code would be readable by every member, allow-listed or not — and whoever acts +// on the code first binds THEIR ChatGPT account to the bot, so every later run +// (and its history) would go through that account. status and cancel stay +// available everywhere: neither reveals a code. +const loginGroupChatText = "Send /login in a direct message with me, not in a group.\n\n" + + "The sign-in reply carries a one-time code that authorizes an account for the whole bot, " + + "and everyone in this group would see it." + // loginNotifyTimeout bounds one background login notice delivery. const loginNotifyTimeout = 30 * time.Second diff --git a/core/chat/reserved.go b/core/chat/reserved.go index 6ed0de1..6356139 100644 --- a/core/chat/reserved.go +++ b/core/chat/reserved.go @@ -25,7 +25,7 @@ var ReservedCommands = []ReservedCommand{ {Name: "stop", Description: "Stop the run currently in progress"}, {Name: "schedule", Description: "Manage scheduled jobs"}, {Name: "goal", Description: "Arm a goal an independent evaluator re-checks after every run"}, - {Name: "login", Description: "Authorize the AI provider (Codex browser sign-in)"}, + {Name: "login", Description: "Sign in to Codex on a subscription (other backends need no login)"}, } // IsReservedCommand reports whether name is one of the bot's reserved commands. diff --git a/core/chat/rungate.go b/core/chat/rungate.go new file mode 100644 index 0000000..a1dbcd7 --- /dev/null +++ b/core/chat/rungate.go @@ -0,0 +1,56 @@ +package chat + +import "context" + +// RunGate reports whether the configured AI provider can run at all right now. +// It exists for one case today: a Codex subscription deployment that has not +// completed its interactive sign-in yet (core/codexauth.Manager satisfies it). +// +// The gate lives HERE, at the single point every run passes through, rather than +// only in the adapters' message paths. Runs reach the provider from five places — +// a user message, a poller-injected PR comment, a cron fire, a workspace +// follow-up, and the restart replay of an interrupted run — and only the first +// of those goes through an adapter. Gating in the adapters alone would let the +// other four march into a CLI that cannot authenticate, once per schedule tick, +// with nothing user-visible to explain it. +type RunGate interface { + // BlockedNotice returns the user-facing explanation and true when runs must + // be blocked, or ("", false) when they may proceed. + BlockedNotice() (string, bool) +} + +// blockRun reports whether this run must be dropped because the provider is +// unauthorized, and tells the chat once why. +// +// The notice is sent at most ONCE per chat per unauthorized period: background +// sources are recurring (a five-minute cron would otherwise post a notice every +// five minutes), and the adapters already answer a user's own message directly. +// The drop is logged every time, so the operator sees the full picture in the +// logs while the chat stays readable. The counter resets as soon as the provider +// is authorized again, so a later lapse is announced afresh. +func (s *Service) blockRun(ctx context.Context, chatID ChatID) bool { + if s.auth == nil { + return false + } + notice, blocked := s.auth.BlockedNotice() + if !blocked { + s.mu.Lock() + delete(s.authNotified, chatID) + s.mu.Unlock() + return false + } + + s.log.Warn("provider is not authorized — dropping run", "chat_id", chatID) + + s.mu.Lock() + first := !s.authNotified[chatID] + s.authNotified[chatID] = true + s.mu.Unlock() + + if first && notice != "" { + if _, err := s.chat.Send(ctx, chatID, notice, "", true); err != nil { + s.log.Error("send unauthorized notice", "chat_id", chatID, "error", err) + } + } + return true +} diff --git a/core/chat/rungate_test.go b/core/chat/rungate_test.go new file mode 100644 index 0000000..5556b90 --- /dev/null +++ b/core/chat/rungate_test.go @@ -0,0 +1,191 @@ +//nolint:testpackage // whitebox: the gate is asserted through the Service's own run paths. +package chat + +import ( + "context" + "log/slog" + "sync/atomic" + "testing" + "time" + + "github.com/duckbugio/flock/core/agent" + "github.com/duckbugio/flock/core/dispatch" + "github.com/duckbugio/flock/core/pending" +) + +// countingRunner records how many runs actually reached the provider. +type countingRunner struct{ n atomic.Int64 } + +func (r *countingRunner) Run(_ context.Context, _ string, _ agent.Options) (<-chan agent.Event, error) { + r.n.Add(1) + out := make(chan agent.Event) + go func() { + defer close(out) + out <- agent.Event{Type: agent.Result, Result: &agent.RunResult{Text: "done", Subtype: "success"}} + }() + return out, nil +} + +func (r *countingRunner) calls() int64 { return r.n.Load() } + +// stubGate is a RunGate whose verdict a test flips at will, standing in for +// codexauth.Manager. It counts consultations, which is the precise signal a run +// reached the gate — letting a test assert "nothing ran" without racing. +type stubGate struct { + blocked atomic.Bool + n atomic.Int64 +} + +func (g *stubGate) BlockedNotice() (string, bool) { + g.n.Add(1) + if !g.blocked.Load() { + return "", false + } + return gateNotice, true +} + +func (g *stubGate) calls() int64 { return g.n.Load() } + +// gateNotice is the text stubGate blocks with. +const gateNotice = "Codex is not authorized yet. Send /login." + +// notices counts how many times the gate's notice was delivered, ignoring the +// ordinary run output an authorized run also sends. +func notices(c *fakeChat) int { + n := 0 + for _, text := range allSent(c) { + if text == gateNotice { + n++ + } + } + return n +} + +// blockingGate returns a gate that starts out blocking. +func blockingGate() *stubGate { + g := &stubGate{} + g.blocked.Store(true) + return g +} + +// gatedService builds a Service whose runs pass through gate. +func gatedService(t *testing.T, r agent.Runner, c Transport, gate RunGate, p pendingStore) *Service { + t.Helper() + d := dispatch.New(4) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = d.Shutdown(ctx) + }) + s := New(Config{ + Runner: r, + Transport: c, + Dispatcher: d, + Workspace: &fakeWorkspace{}, + Pending: p, + Auth: gate, + Logger: slog.New(slog.DiscardHandler), + }) + s.tick = 5 * time.Millisecond + return s +} + +// TestGateBlocksEveryRunSource is the point of gating in the Service rather than +// only in the adapters: a user message is just one of four ways a run starts +// here. A cron fire, a poller relay and the restart replay never touch an +// adapter, so an adapter-only gate would march all of them into a CLI that +// cannot authenticate — once per schedule tick, with nothing user-visible. +func TestGateBlocksEveryRunSource(t *testing.T) { + tests := []struct { + name string + start func(s *Service) + }{ + {"user message", func(s *Service) { s.Handle(context.Background(), testChatID, 7, "m1", "build it") }}, + {"poller relay", func(s *Service) { s.Inject(testChatID, "a reviewer commented") }}, + {"cron fire", func(s *Service) { s.InjectScheduled(testChatID, "nightly", 7) }}, + {"restart replay", func(s *Service) { s.ResumePending(testChatID, pending.Marker{ID: "p1", Prompt: "resume me"}) }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, r, gate := newFakeChat(), &countingRunner{}, blockingGate() + s := gatedService(t, r, c, gate, nil) + + tt.start(s) + waitUntil(t, func() bool { return gate.calls() >= 1 }) + + if got := r.calls(); got != 0 { + t.Errorf("the provider ran %d times while unauthorized, want 0", got) + } + waitUntil(t, func() bool { return notices(c) == 1 }) + }) + } +} + +// TestGateNotifiesOnceUntilAuthorized: background sources recur (a five-minute +// cron would otherwise post a notice every five minutes), so the chat is told +// once — and told again after a later lapse, or a returning outage would go +// unexplained. +func TestGateNotifiesOnceUntilAuthorized(t *testing.T) { + c, gate := newFakeChat(), blockingGate() + s := gatedService(t, &countingRunner{}, c, gate, nil) + + s.Inject(testChatID, "first") + s.Inject(testChatID, "second") + s.Inject(testChatID, "third") + waitUntil(t, func() bool { return gate.calls() >= 3 }) + + if got := notices(c); got != 1 { + t.Fatalf("sent %d notices for three blocked runs, want 1: %v", got, allSent(c)) + } + + // Authorized again — the "already told them" memory must reset. + gate.blocked.Store(false) + s.Inject(testChatID, "now allowed") + waitUntil(t, func() bool { return gate.calls() >= 4 }) + + // ...and a fresh lapse is announced again. + gate.blocked.Store(true) + s.Inject(testChatID, "blocked again") + waitUntil(t, func() bool { return notices(c) == 2 }) +} + +// TestGateKeepsThePendingMarker: a blocked replay must NOT clear the marker of +// the run it refused, or an interrupted run would be lost to the very outage +// that stopped it. The marker survives to be replayed after the sign-in. +func TestGateKeepsThePendingMarker(t *testing.T) { + c, p, gate := newFakeChat(), newFakePending(), blockingGate() + s := gatedService(t, &countingRunner{}, c, gate, p) + + if _, err := p.Enqueue(testChatID, pending.Marker{ID: "p1", Prompt: "resume me"}); err != nil { + t.Fatalf("seed marker: %v", err) + } + s.ResumePending(testChatID, pending.Marker{ID: "p1", Prompt: "resume me"}) + waitUntil(t, func() bool { return gate.calls() >= 1 }) + + if got := p.removes(); len(got) != 0 { + t.Errorf("a blocked run cleared the pending marker (%v); the interrupted work is lost", got) + } + if p.count() != 1 { + t.Errorf("pending markers = %d, want the interrupted run still queued", p.count()) + } +} + +// TestGateAllowsRunsWhenAuthorized keeps the gate honest: an authorized gate — +// or none at all, which is every non-Codex deployment — must not change behavior. +func TestGateAllowsRunsWhenAuthorized(t *testing.T) { + for _, tt := range []struct { + name string + gate RunGate + }{ + {"authorized gate", &stubGate{}}, + {"no gate wired", nil}, + } { + t.Run(tt.name, func(t *testing.T) { + c, r := newFakeChat(), &countingRunner{} + s := gatedService(t, r, c, tt.gate, nil) + + s.Handle(context.Background(), testChatID, 7, "m1", "build it") + waitUntil(t, func() bool { return r.calls() == 1 }) + }) + } +} diff --git a/core/chat/service.go b/core/chat/service.go index e07780e..8bb756f 100644 --- a/core/chat/service.go +++ b/core/chat/service.go @@ -128,12 +128,14 @@ type Service struct { log *slog.Logger tick time.Duration nowFunc func() time.Time + auth RunGate - mu sync.Mutex // guards runChat, lastMsg, verifyRetry, budgetNotified and snapCache + mu sync.Mutex // guards runChat, lastMsg, verifyRetry, budgetNotified, authNotified and snapCache runChat map[string]ChatID // active runID -> chatID, for mapping Stop back to a chat lastMsg map[ChatID]MessageID // chatID -> the source message id of its latest submitted run verifyRetry map[ChatID][]string // repos whose gate failed last round (re-gated even if unchanged) budgetNotified map[ChatID]string // chatID -> UTC day the budget-reached notice was last sent + authNotified map[ChatID]bool // chatID -> the "provider unauthorized" notice was already sent snapCache map[ChatID]verify.Snapshot // post-run repo fingerprints, reused as the next run's "before" runSeq atomic.Uint64 } @@ -179,7 +181,12 @@ type Config struct { // (Telegram supplies its 429 detector here). Nil means the transport has no // rate-limit signal, so a delivery error is never treated as throttling. RetryAfter func(err error) (time.Duration, bool) - Logger *slog.Logger + // Auth blocks every run while the configured provider is unauthorized (today: + // a Codex subscription deploy whose interactive sign-in has not happened yet). + // Nil means no gate — the Claude and API-key paths authenticate from env and + // have nothing to wait for. + Auth RunGate + Logger *slog.Logger } // defaultMaxMessageRunes is the chunk limit used when a Transport reports a @@ -221,11 +228,13 @@ func New(cfg Config) *Service { log: log, tick: tickInterval, nowFunc: time.Now, + auth: cfg.Auth, runChat: map[string]ChatID{}, lastMsg: map[ChatID]MessageID{}, verifyRetry: map[ChatID][]string{}, budgetNotified: map[ChatID]string{}, + authNotified: map[ChatID]bool{}, snapCache: map[ChatID]verify.Snapshot{}, } } @@ -404,6 +413,15 @@ func (s *Service) Stop(runID string) bool { func (s *Service) run( ctx context.Context, chatID ChatID, userID int64, prompt string, images []agent.ImageInput, markerID string, ) { + // The provider gate, at the single point EVERY run passes through: a user + // message, a poller-injected PR comment, a cron fire, a workspace follow-up and + // the restart replay all land here. A blocked run is dropped before any work — + // and before the pending marker would be cleared, so an interrupted run stays + // queued for after the sign-in instead of being lost to it. + if s.blockRun(ctx, chatID) { + return + } + runID := strconv.FormatUint(s.runSeq.Add(1), 10) s.mu.Lock() s.runChat[runID] = chatID diff --git a/core/codexauth/codexauth.go b/core/codexauth/codexauth.go index da742b5..da27a63 100644 --- a/core/codexauth/codexauth.go +++ b/core/codexauth/codexauth.go @@ -134,6 +134,7 @@ type Manager struct { cancel context.CancelFunc last Prompt hasLast bool + subs []Subscriber } // NewManager returns a Manager for cfg. @@ -161,13 +162,23 @@ func (m *Manager) Authorized() bool { if !m.Applicable() || !m.cfg.RequireAuth { return true } - return m.credentialsPresent() + return m.CredentialsPresent() } -// credentialsPresent reports whether Codex has something to authenticate WITH, -// independent of whether the deployment demands it. It is the honest answer -// /login reasons about; Authorized is the policy answer runs are gated on. -func (m *Manager) credentialsPresent() bool { +// CredentialsPresent reports whether Codex has something to authenticate WITH: a +// configured access token, or a persisted auth.json under CODEX_HOME. +// +// This is the FACTUAL answer; Authorized is the POLICY answer runs are gated on, +// and the two deliberately differ when CODEX_REQUIRE_AUTH=false — that deploy has +// no credential and is not blocked. Startup logs both, because "authorized=true" +// alone would reassure an operator in exactly the configuration where the first +// run is about to fail inside the CLI. +// +// A present auth.json is not proof the credential still WORKS: an expired or +// corrupt token reads as present. Re-validating would mean spawning +// `codex login status` on every message, which is far too expensive for the +// message path; /login force re-authenticates when a stale credential shows up. +func (m *Manager) CredentialsPresent() bool { if m == nil { return false } @@ -218,7 +229,7 @@ func (m *Manager) StatusText() string { return "Codex device login is starting; the link and code arrive in a moment." case m.cfg.HasAccessToken: return "Codex is authorized with a configured access token. No browser login needed." - case m.credentialsPresent(): + case m.CredentialsPresent(): return "Codex is authorized (persisted login in " + m.cfg.Home + ")." case !m.cfg.RequireAuth: return "No persisted Codex login found, but CODEX_REQUIRE_AUTH=false, so runs are not blocked. " + @@ -254,26 +265,63 @@ const LoginUsage = "Usage:\n" + "/login cancel — abort a pending sign-in\n" + "/login force — sign in again even if already authorized" +// Subscriber is where a pending login's background notices are delivered. ID +// identifies the destination (a chat id) so the same destination asking twice is +// collapsed to one subscription rather than being told everything twice; Notify +// does the delivery and may be called minutes later, from another goroutine, so +// it must build its own context rather than close over the update's. +type Subscriber struct { + ID string + Notify func(string) +} + +// deliver sends text when the subscriber can receive it. +func (s Subscriber) deliver(text string) { + if s.Notify != nil { + s.Notify(text) + } +} + +// StartsLogin reports whether these /login arguments would begin a sign-in, as +// opposed to merely reporting or cancelling one. Adapters use it to refuse the +// code-revealing forms in a group chat while leaving status and cancel available +// everywhere; it shares Dispatch's switch so the two can never disagree about +// which arguments print a code. +func StartsLogin(args string) bool { + switch normalizeArgs(args) { + case "", "force", "again", "relogin": + return true + default: + return false + } +} + +// normalizeArgs canonicalizes a /login argument for comparison. +func normalizeArgs(args string) string { return strings.ToLower(strings.TrimSpace(args)) } + // Dispatch serves the /login command and returns the IMMEDIATE reply. // // The device flow outlives the command: after the immediate reply, the pending // login keeps running in the background and reports the verification link, the -// one-time code, and the final outcome through notify. notify may therefore be -// called minutes later and from another goroutine — the caller must make it safe -// to use then (its own context, not the update's). +// one-time code, and the final outcome to every subscriber. +// +// Subscribing is per Dispatch call, so a SECOND /login (from another chat, or the +// same one) both re-shows the pending code and starts receiving the outcome — +// without it, only the chat that happened to start the login would ever learn +// whether it succeeded. // // base only seeds the login's values; its cancellation does NOT abort the login // (the update's context is dead long before a user finishes in a browser). // /login cancel, the timeout, and process shutdown are the ways it ends. -func (m *Manager) Dispatch(base context.Context, args string, notify func(string)) string { +func (m *Manager) Dispatch(base context.Context, args string, sub Subscriber) string { if !m.Applicable() { return m.notApplicableText() } - switch strings.ToLower(strings.TrimSpace(args)) { + switch normalizeArgs(args) { case "": - return m.start(base, false, notify) + return m.start(base, false, sub) case "force", "again", "relogin": - return m.start(base, true, notify) + return m.start(base, true, sub) case "status": return m.StatusText() case "cancel", "abort", "stop": @@ -291,9 +339,10 @@ func (m *Manager) Dispatch(base context.Context, args string, notify func(string // start launches a device login unless one is already pending (in which case the // pending code is re-shown, never replaced) or Codex is already authorized and // force was not asked for. -func (m *Manager) start(base context.Context, force bool, notify func(string)) string { +func (m *Manager) start(base context.Context, force bool, sub Subscriber) string { m.mu.Lock() if m.running { + m.subscribeLocked(sub) last, hasLast := m.last, m.hasLast m.mu.Unlock() if hasLast { @@ -301,10 +350,10 @@ func (m *Manager) start(base context.Context, force bool, notify func(string)) s } return "A Codex login is already starting; the link and code arrive in a moment." } - // credentialsPresent, not Authorized: with CODEX_REQUIRE_AUTH=false the policy + // CredentialsPresent, not Authorized: with CODEX_REQUIRE_AUTH=false the policy // answer is always "authorized", which would make /login unusable on exactly // the deployments most likely to need it. - if !force && m.credentialsPresent() { + if !force && m.CredentialsPresent() { m.mu.Unlock() return m.StatusText() + "\n\nSend /login force to sign in again." } @@ -316,14 +365,41 @@ func (m *Manager) start(base context.Context, force bool, notify func(string)) s m.cancel = cancel m.hasLast = false m.last = Prompt{} + m.subs = nil + m.subscribeLocked(sub) m.mu.Unlock() - go m.run(ctx, cancel, notify) + go m.run(ctx, cancel) return "Starting the Codex sign-in — the link and one-time code arrive in a moment." } -// run drives one login attempt and reports its result through notify. -func (m *Manager) run(ctx context.Context, cancel context.CancelFunc, notify func(string)) { +// subscribeLocked adds sub to the pending login's notice list, collapsing a +// destination that is already subscribed. The caller must hold m.mu. +func (m *Manager) subscribeLocked(sub Subscriber) { + if sub.Notify == nil { + return + } + for _, existing := range m.subs { + if existing.ID == sub.ID { + return + } + } + m.subs = append(m.subs, sub) +} + +// broadcast delivers text to every current subscriber. The list is copied under +// the lock so a delivery (which does network I/O) never holds it. +func (m *Manager) broadcast(text string) { + m.mu.Lock() + subs := append([]Subscriber(nil), m.subs...) + m.mu.Unlock() + for _, sub := range subs { + sub.deliver(text) + } +} + +// run drives one login attempt and reports its result to every subscriber. +func (m *Manager) run(ctx context.Context, cancel context.CancelFunc) { defer cancel() log := m.cfg.logger() @@ -332,9 +408,7 @@ func (m *Manager) run(ctx context.Context, cancel context.CancelFunc, notify fun m.last, m.hasLast = p, true m.mu.Unlock() log.Info("codex device login prompt issued", "url", p.URL, "expires_in", p.ExpiresIn) - if notify != nil { - notify(promptText(p)) - } + m.broadcast(promptText(p)) }) m.mu.Lock() @@ -342,16 +416,13 @@ func (m *Manager) run(ctx context.Context, cancel context.CancelFunc, notify fun m.cancel = nil m.mu.Unlock() - if notify == nil { - return - } if err == nil { log.Info("codex device login succeeded", "codex_home", m.cfg.Home) - notify("Codex is authorized. Send your next message and the team gets to work.") + m.broadcast("Codex is authorized. Send your next message and the team gets to work.") return } log.Warn("codex device login failed", "error", err) - notify(failureText(err)) + m.broadcast(failureText(err)) } // Cancel aborts a pending login, reporting whether there was one. diff --git a/core/codexauth/codexauth_test.go b/core/codexauth/codexauth_test.go index 51c23c1..936acd8 100644 --- a/core/codexauth/codexauth_test.go +++ b/core/codexauth/codexauth_test.go @@ -27,6 +27,9 @@ func newCollector() *collector { return &collector{ch: make(chan string, 8)} } func (c *collector) notify(text string) { c.ch <- text } +// sub is the collector as a Subscriber for destination id. +func (c *collector) sub(id string) Subscriber { return Subscriber{ID: id, Notify: c.notify} } + // next returns the next notice, failing the test if none arrives in time. func (c *collector) next(t *testing.T) string { t.Helper() @@ -151,7 +154,7 @@ func TestNilManagerIsInert(t *testing.T) { if m.Cancel() { t.Error("nil Manager cancelled a login") } - if got := m.Dispatch(context.Background(), "", nil); got != NoLoginNeededText { + if got := m.Dispatch(context.Background(), "", Subscriber{}); got != NoLoginNeededText { t.Errorf("nil Manager Dispatch = %q, want NoLoginNeededText", got) } } @@ -160,11 +163,11 @@ func TestNilManagerIsInert(t *testing.T) { // itself instead of running anything. func TestDispatchNotApplicable(t *testing.T) { claude := NewManager(Config{Backend: "claude"}) - if got := claude.Dispatch(context.Background(), "", nil); !strings.Contains(got, "claude") { + if got := claude.Dispatch(context.Background(), "", Subscriber{}); !strings.Contains(got, "claude") { t.Errorf("Dispatch = %q, want it to name the configured backend", got) } billing := NewManager(Config{Backend: BackendCodex, AuthMode: AuthBilling}) - if got := billing.Dispatch(context.Background(), "", nil); !strings.Contains(got, "CODEX_API_KEY") { + if got := billing.Dispatch(context.Background(), "", Subscriber{}); !strings.Contains(got, "CODEX_API_KEY") { t.Errorf("Dispatch = %q, want the billing-mode explanation", got) } } @@ -192,7 +195,7 @@ func TestDispatchFullLoginPath(t *testing.T) { } c := newCollector() - if reply := m.Dispatch(context.Background(), "", c.notify); !strings.Contains(reply, "Starting") { + if reply := m.Dispatch(context.Background(), "", c.sub("chat-1")); !strings.Contains(reply, "Starting") { t.Errorf("immediate reply = %q, want an acknowledgement that the sign-in started", reply) } @@ -232,12 +235,12 @@ func TestDispatchReshowsPendingPrompt(t *testing.T) { t.Cleanup(func() { m.Cancel() }) c := newCollector() - m.Dispatch(context.Background(), "", c.notify) + m.Dispatch(context.Background(), "", c.sub("chat-1")) if prompt := c.next(t); !strings.Contains(prompt, "XER9-NWCA2") { t.Fatalf("first prompt %q is missing the code", prompt) } - again := m.Dispatch(context.Background(), "", c.notify) + again := m.Dispatch(context.Background(), "", c.sub("chat-1")) if !strings.Contains(again, "XER9-NWCA2") { t.Errorf("second /login = %q, want the pending code re-shown", again) } @@ -267,15 +270,15 @@ func TestDispatchCancel(t *testing.T) { Env: []string{"PATH=" + os.Getenv("PATH")}, }) - if got := m.Dispatch(context.Background(), "cancel", nil); !strings.Contains(got, "No Codex login is pending") { + if got := m.Dispatch(context.Background(), "cancel", Subscriber{}); !strings.Contains(got, "No Codex login is pending") { t.Errorf("cancel with nothing pending = %q", got) } c := newCollector() - m.Dispatch(context.Background(), "", c.notify) + m.Dispatch(context.Background(), "", c.sub("chat-1")) c.next(t) // the prompt - if got := m.Dispatch(context.Background(), "cancel", c.notify); !strings.Contains(got, "Cancelled") { + if got := m.Dispatch(context.Background(), "cancel", c.sub("chat-1")); !strings.Contains(got, "Cancelled") { t.Errorf("cancel = %q, want a cancellation acknowledgement", got) } if got := c.next(t); !strings.Contains(got, "cancelled") { @@ -291,7 +294,7 @@ func TestDispatchAlreadyAuthorized(t *testing.T) { writeAuthFile(t, home) m := NewManager(Config{Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: home}) - got := m.Dispatch(context.Background(), "", nil) + got := m.Dispatch(context.Background(), "", Subscriber{}) if !strings.Contains(got, "authorized") || !strings.Contains(got, "/login force") { t.Errorf("Dispatch = %q, want the authorized state plus the force hint", got) } @@ -301,13 +304,13 @@ func TestDispatchAlreadyAuthorized(t *testing.T) { func TestDispatchStatusAndUsage(t *testing.T) { m := NewManager(Config{Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: t.TempDir()}) - if got := m.Dispatch(context.Background(), "status", nil); !strings.Contains(got, "NOT authorized") { + if got := m.Dispatch(context.Background(), "status", Subscriber{}); !strings.Contains(got, "NOT authorized") { t.Errorf("status = %q, want the unauthorized state", got) } - if got := m.Dispatch(context.Background(), "help", nil); got != LoginUsage { + if got := m.Dispatch(context.Background(), "help", Subscriber{}); got != LoginUsage { t.Errorf("help = %q, want LoginUsage", got) } - if got := m.Dispatch(context.Background(), "wat", nil); !strings.Contains(got, "Unknown /login argument") { + if got := m.Dispatch(context.Background(), "wat", Subscriber{}); !strings.Contains(got, "Unknown /login argument") { t.Errorf("unknown arg = %q, want the usage hint", got) } } @@ -341,7 +344,7 @@ func TestDispatchSurvivesADeadRequestContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) c := newCollector() - m.Dispatch(ctx, "", c.notify) + m.Dispatch(ctx, "", c.sub("chat-1")) cancel() // the update's context dies immediately, as it does in production if prompt := c.next(t); !strings.Contains(prompt, "XER9-NWCA2") { @@ -400,3 +403,79 @@ func TestSubscriptionLoginDropsInheritedAPIKey(t *testing.T) { t.Errorf("CODEX_HOME = %q, want the configured home", env[len(env)-1]) } } + +// TestStartsLogin pins which arguments print a code — the adapters gate group +// chats on exactly this, so it must agree with Dispatch's own switch. +func TestStartsLogin(t *testing.T) { + for _, args := range []string{"", " ", "force", "FORCE", "again", "relogin"} { + if !StartsLogin(args) { + t.Errorf("StartsLogin(%q) = false, want true (this form reveals a code)", args) + } + } + for _, args := range []string{"status", "cancel", "abort", "help", "nonsense"} { + if StartsLogin(args) { + t.Errorf("StartsLogin(%q) = true, want false", args) + } + } +} + +// TestEveryAskerLearnsTheOutcome: a second /login while one is pending re-shows +// the same code — and must also start receiving the verdict. Without the +// subscription, only the chat that happened to start the login would ever learn +// whether it worked. +func TestEveryAskerLearnsTheOutcome(t *testing.T) { + bin := fakeCodex(t, printBanner+"sleep 0.3\nexit 0\n") + m := NewManager(Config{ + Backend: BackendCodex, + AuthMode: AuthSubscription, + RequireAuth: true, + Bin: bin, + Home: t.TempDir(), + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + + first, second := newCollector(), newCollector() + m.Dispatch(context.Background(), "", first.sub("chat-1")) + if p := first.next(t); !strings.Contains(p, "XER9-NWCA2") { + t.Fatalf("first prompt %q is missing the code", p) + } + + // A different chat asks while the login is pending. + if again := m.Dispatch(context.Background(), "", second.sub("chat-2")); !strings.Contains(again, "XER9-NWCA2") { + t.Fatalf("second /login = %q, want the pending code re-shown", again) + } + + for name, c := range map[string]*collector{"starter": first, "joiner": second} { + if got := c.next(t); !strings.Contains(strings.ToLower(got), "authorized") { + t.Errorf("%s got %q as the final notice, want the success confirmation", name, got) + } + } +} + +// TestTheSameChatAskingTwiceIsNotToldTwice: subscriptions are keyed by +// destination, so a user tapping /login twice does not double every later notice. +func TestTheSameChatAskingTwiceIsNotToldTwice(t *testing.T) { + bin := fakeCodex(t, printBanner+"sleep 0.3\nexit 0\n") + m := NewManager(Config{ + Backend: BackendCodex, + AuthMode: AuthSubscription, + RequireAuth: true, + Bin: bin, + Home: t.TempDir(), + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + + c := newCollector() + m.Dispatch(context.Background(), "", c.sub("chat-1")) + c.next(t) // the prompt + m.Dispatch(context.Background(), "", c.sub("chat-1")) + + if got := c.next(t); !strings.Contains(strings.ToLower(got), "authorized") { + t.Fatalf("final notice = %q, want the success confirmation", got) + } + select { + case extra := <-c.ch: + t.Errorf("the same chat was told twice; extra notice: %q", extra) + case <-time.After(300 * time.Millisecond): + } +} diff --git a/core/codexauth/login.go b/core/codexauth/login.go index 5b92756..87bf770 100644 --- a/core/codexauth/login.go +++ b/core/codexauth/login.go @@ -132,20 +132,37 @@ func Login(ctx context.Context, cfg Config, onPrompt func(Prompt)) error { // Both streams are parsed: --device-auth prints the prompt on stdout, while // diagnostics (including the "use --device-auth" hint and argument errors) // arrive on stderr. Whichever carries the URL and code, we find it. + // + // Each stream gets its OWN parser, and a prompt is only released when the URL + // and the code came from the SAME one. The two readers are separate goroutines, + // so a shared parser would interleave them in nondeterministic order: a stray + // URL on stderr (an upgrade notice, a policy link) could then be paired with the + // real code from stdout and the user would be sent, silently, to the wrong page + // with a valid code. Only one prompt is emitted overall, whichever stream + // completes a pair first. out := newTail(outputTailBytes) - var parseMu sync.Mutex - parser := &promptParser{} + var ( + emitMu sync.Mutex + emitted bool + sawPromptOnAny bool + ) var streams sync.WaitGroup for _, rd := range []io.Reader{stdout, stderr} { streams.Add(1) go func(rd io.Reader) { defer streams.Done() + parser := &promptParser{} scanLines(rd, func(line string) { out.WriteString(line + "\n") - parseMu.Lock() p, ok := parser.feed(line) - parseMu.Unlock() - if ok && onPrompt != nil { + if !ok { + return + } + emitMu.Lock() + first := !emitted + emitted, sawPromptOnAny = true, true + emitMu.Unlock() + if first && onPrompt != nil { onPrompt(p) } }) @@ -162,9 +179,9 @@ func Login(ctx context.Context, cfg Config, onPrompt func(Prompt)) error { } killMu.Unlock() - parseMu.Lock() - sawPrompt := parser.sent - parseMu.Unlock() + emitMu.Lock() + sawPrompt := sawPromptOnAny + emitMu.Unlock() return loginResult(ctx, waitErr, out.String(), sawPrompt) } @@ -278,10 +295,11 @@ var ( // is actionable; the prompt is released exactly once, on the line that completes // the pair. type promptParser struct { - url string - code string - ttl time.Duration - sent bool + url string + urlScore int + code string + ttl time.Duration + sent bool } // feed consumes one ANSI-stripped output line. ok is true only for the line that @@ -290,9 +308,14 @@ func (p *promptParser) feed(line string) (Prompt, bool) { if p.sent { return Prompt{}, false } - if p.url == "" { - if m := urlRe.FindString(line); m != "" { - p.url = strings.TrimRight(m, ".,;") + if m := urlRe.FindString(line); m != "" { + // Keep the BEST candidate rather than the first: an upgrade notice or a + // policy link printed before the verification URL must not win just because + // it came first, or the user would be sent to the wrong page with a code + // that looks perfectly valid. A single-URL run is unaffected — it scores + // whatever it scores and stays. + if candidate := strings.TrimRight(m, ".,;"); p.url == "" || urlScore(candidate) > p.urlScore { + p.url, p.urlScore = candidate, urlScore(candidate) } } if p.code == "" { @@ -310,6 +333,30 @@ func (p *promptParser) feed(line string) (Prompt, bool) { return Prompt{URL: p.url, Code: p.code, ExpiresIn: p.ttl}, true } +// URL candidate ranks, best first: the device path is unmistakable, a known host +// is good evidence, and anything else is only used for lack of an alternative. +const ( + scoreDevicePath = 2 + scoreKnownHost = 1 + scoreUnknown = 0 +) + +// urlScore ranks a URL by how much it looks like Codex's device-verification +// link. It is a preference, never a filter: an unrecognized URL is still used +// when it is the only one, so a changed domain degrades to today's behavior +// instead of breaking the flow outright. +func urlScore(u string) int { + low := strings.ToLower(u) + switch { + case strings.Contains(low, "/device"): + return scoreDevicePath + case strings.Contains(low, "openai.com"), strings.Contains(low, "chatgpt.com"): + return scoreKnownHost + default: + return scoreUnknown + } +} + // matchCode extracts a one-time code from line. A bare uppercase hyphenated // token is only accepted when it is the WHOLE line (how Codex prints it) or when // the line is explicitly about a code — otherwise an unrelated token in a banner diff --git a/core/codexauth/login_test.go b/core/codexauth/login_test.go index 17d6dd7..967b967 100644 --- a/core/codexauth/login_test.go +++ b/core/codexauth/login_test.go @@ -27,6 +27,13 @@ const deviceAuthBanner = "\n" + "2. Enter this one-time code \x1b[90m(expires in 15 minutes)\x1b[0m\n" + " \x1b[94mXER9-NWCA2\x1b[0m\n" +// The verification link and one-time code the banner carries, asserted across +// the parser and end-to-end cases. +const ( + deviceURL = "https://auth.openai.com/codex/device" + deviceCode = "XER9-NWCA2" +) + // printBanner is the shell snippet emitting deviceAuthBanner verbatim. A quoted // heredoc keeps the apostrophes and escapes intact without shell interpretation. const printBanner = "cat <<'CODEX_BANNER_EOF'" + deviceAuthBanner + "CODEX_BANNER_EOF\n" @@ -86,10 +93,10 @@ func TestLoginEmitsPromptWhileProcessStillRunning(t *testing.T) { t.Fatal("no prompt within 10s: the CLI output is not being read incrementally") } - if got.URL != "https://auth.openai.com/codex/device" { + if got.URL != deviceURL { t.Errorf("URL = %q, want the device verification link", got.URL) } - if got.Code != "XER9-NWCA2" { + if got.Code != deviceCode { t.Errorf("Code = %q, want XER9-NWCA2", got.Code) } if got.ExpiresIn != 15*time.Minute { @@ -240,7 +247,7 @@ func TestPromptParser(t *testing.T) { if !ok { t.Fatal("the code line did not release the prompt") } - if got.URL != "https://auth.openai.com/codex/device" || got.Code != "XER9-NWCA2" { + if got.URL != deviceURL || got.Code != deviceCode { t.Errorf("prompt = %+v, want the parsed URL and code", got) } if got.ExpiresIn != 15*time.Minute { @@ -260,8 +267,8 @@ func TestMatchCode(t *testing.T) { line string want string }{ - {"bare code line", " XER9-NWCA2 ", "XER9-NWCA2"}, - {"labelled code", "Your one-time code: XER9-NWCA2 now", "XER9-NWCA2"}, + {"bare code line", " XER9-NWCA2 ", deviceCode}, + {"labelled code", "Your one-time code: XER9-NWCA2 now", deviceCode}, {"inside a url", "https://example.com/AAAA-BBBB", ""}, {"unrelated banner token", "Release NOTES-DRAFT ready", ""}, {"lowercase", " xer9-nwca2", ""}, @@ -341,3 +348,74 @@ func TestSplitLinesBreaksOnCarriageReturn(t *testing.T) { t.Errorf("advance = %d, want %d", advance, len("waiting…")+1) } } + +// TestLoginIgnoresAStrayURLFromTheOtherStream is the anti-mispairing guard. The +// two output streams are read by separate goroutines, so a shared parser would +// interleave them nondeterministically: an unrelated URL on stderr (an upgrade +// notice, a policy link) could be paired with the REAL code from stdout, and the +// user would be sent to the wrong page holding a code that works. The URL and +// the code must come from the same stream. +func TestLoginIgnoresAStrayURLFromTheOtherStream(t *testing.T) { + bin := fakeCodex(t, "echo 'A new version is available: https://example.com/upgrade' >&2\n"+ + "sleep 0.2\n"+printBanner+"exit 0\n") + + var got Prompt + if err := Login(context.Background(), testConfig(t, bin), func(p Prompt) { got = p }); err != nil { + t.Fatalf("Login = %v, want nil", err) + } + if got.URL != deviceURL { + t.Errorf("URL = %q, want the verification link, not the stray stderr one", got.URL) + } + if got.Code != deviceCode { + t.Errorf("Code = %q, want XER9-NWCA2", got.Code) + } +} + +// TestPromptParserPrefersTheVerificationURL: within one stream, a URL printed +// before the verification link must not win merely by being first. +func TestPromptParserPrefersTheVerificationURL(t *testing.T) { + p := &promptParser{} + p.feed("Docs: https://example.com/help") + p.feed(" https://auth.openai.com/codex/device") + got, ok := p.feed(" XER9-NWCA2") + if !ok { + t.Fatal("no prompt released") + } + if got.URL != deviceURL { + t.Errorf("URL = %q, want the device link to outrank the earlier one", got.URL) + } +} + +// TestPromptParserKeepsALoneUnrecognizedURL: the preference is a ranking, not a +// filter — if Codex ever moves off these domains, the only URL on offer is still +// used rather than the flow breaking outright. +func TestPromptParserKeepsALoneUnrecognizedURL(t *testing.T) { + p := &promptParser{} + p.feed(" https://login.example.test/activate") + got, ok := p.feed(" XER9-NWCA2") + if !ok { + t.Fatal("no prompt released") + } + if got.URL != "https://login.example.test/activate" { + t.Errorf("URL = %q, want the single available URL", got.URL) + } +} + +// TestURLScore pins the ranking. +func TestURLScore(t *testing.T) { + tests := []struct { + url string + want int + }{ + {deviceURL, scoreDevicePath}, + {"https://chatgpt.com/codex/settings", scoreKnownHost}, + {"https://example.com/upgrade", scoreUnknown}, + } + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + if got := urlScore(tt.url); got != tt.want { + t.Errorf("urlScore(%q) = %d, want %d", tt.url, got, tt.want) + } + }) + } +} diff --git a/docs/codex-integration-plan.md b/docs/codex-integration-plan.md index b3f6f15..885eada 100644 --- a/docs/codex-integration-plan.md +++ b/docs/codex-integration-plan.md @@ -516,6 +516,16 @@ Delivered: (`airunner.BuildWithPendingLogin`); runs are blocked with an actionable notice until the login lands, re-checked from `auth.json` on every message so no restart is needed. +- The block lives in `core/chat` (`RunGate`), at the single point every run + passes through, so the four non-adapter sources — a poller relay, a cron fire, + a workspace follow-up and the restart replay — are gated too instead of + marching into an unauthenticated CLI once per tick. The adapters keep an early + check on the message path so an unauthorized deploy never pays for a voice + transcription or an upload download. A blocked replay keeps its pending marker. +- `/login` starts a sign-in only in a direct message; `status` and `cancel` work + anywhere. `CODEX_REQUIRE_AUTH=false` still opts out of blocking entirely, and + startup logs `authorized` next to `credentials_present` so that opt-out cannot + read as "all good". Acceptance: @@ -565,8 +575,9 @@ Acceptance: 2. Start the container. With no persisted `auth.json` and no `CODEX_ACCESS_TOKEN` the bot comes up in a "needs login" state: it logs a warning, serves `/login`, and answers any other message with a notice pointing at it. -3. From an allow-listed chat, send `/login`. The bot replies with a verification - link and a one-time code. +3. From an allow-listed **direct message** (not a group — the reply carries a + one-time code that would authorize whoever acts on it first), send `/login`. + The bot replies with a verification link and a one-time code. 4. Open the link, sign in, and enter the code. The bot confirms in the chat. 5. Send a small prompt in a private allowed chat — no restart needed. diff --git a/internal/airunner/backend.go b/internal/airunner/backend.go index 5a9f39e..036168b 100644 --- a/internal/airunner/backend.go +++ b/internal/airunner/backend.go @@ -4,6 +4,7 @@ package airunner import ( "errors" "fmt" + "log/slog" "os" "strings" @@ -119,6 +120,58 @@ func IsPendingLogin(err error) bool { return errors.Is(err, config.ErrCodexSubscriptionAuthRequired) } +// BuildProvider is the whole provider-startup sequence both adapter binaries +// run: resolve the provider (tolerating a pending Codex sign-in), build the +// codexauth.Manager that serves /login and gates runs, and log what came up. It +// lives here rather than in either main so the two cannot drift apart. +// +// The Codex log reports authorized AND credentials_present, because they are +// different questions and only reporting the first would mislead: with +// CODEX_REQUIRE_AUTH=false, Authorized is true by policy even when there is no +// credential at all — precisely the deploy whose first run is about to fail +// inside the CLI, and precisely the log an operator reads first. +// +// The error is logged here; the caller only needs to know to exit. +func BuildProvider(cfg config.Config, logger *slog.Logger) ( + agent.Runner, agent.Options, agent.ProviderInfo, *codexauth.Manager, error, +) { + if logger == nil { + logger = slog.Default() + } + runner, opts, provider, pendingLogin, err := BuildWithPendingLogin(cfg) + if err != nil { + logger.Error("invalid ai provider config", "provider", cfg.AIBackend, "error", err) + return nil, agent.Options{}, agent.ProviderInfo{}, nil, err + } + auth := codexauth.NewManager(CodexAuthConfig(cfg, provider)) + if pendingLogin { + logger.Warn("codex is not authorized yet; runs are blocked until /login completes", + "codex_home", cfg.CodexHome) + } + if provider.Name == config.AIBackendCodex { + logger.Info("codex backend enabled", + "provider", provider.Name, + "display_name", provider.DisplayName, + "auth_mode", cfg.CodexAuthModeName(), + "authorized", auth.Authorized(), + "credentials_present", auth.CredentialsPresent(), + "require_auth", cfg.CodexRequireAuth, + "sandbox", cfg.CodexSandbox, + "approval_policy", cfg.CodexApprovalPolicy, + "codex_home", cfg.CodexHome, + "capabilities", provider.Capabilities, + ) + } else { + logger.Info("ai provider enabled", + "provider", provider.Name, + "display_name", provider.DisplayName, + "model", opts.Model, + "capabilities", provider.Capabilities, + ) + } + return runner, opts, provider, auth, nil +} + // CodexAuthConfig derives the /login driver's configuration from the deployment // config and the resolved provider. It passes the SAME child environment the // runner uses (CodexEnv), so the login writes auth.json into exactly the From f3175fdab7c8ddac18a5931c93bf2d533a89a34b Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 12:49:00 +0300 Subject: [PATCH 04/21] fix(codex): decide code visibility by destination, and refuse before markers exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both blockers from the second review on #66 were real, and the first was mine. BLOCKER — /login status printed the one-time code into a group. The guard I added checked the ARGUMENTS (StartsLogin), but `status` also re-shows a pending code via StatusText's running branch, so it walked straight past — the exact leak the guard existed to prevent, while the code comment and PR text claimed the opposite. An argument-based check is the wrong shape: every new code-bearing reply would have to remember to opt in. The decision moved into codexauth, keyed on the destination. Subscriber carries Private, and the Manager itself redacts on every path that could carry a code: starting a sign-in is refused, a pending code is replaced by a pointer to the direct message that owns it, and a non-private destination is never subscribed to the prompt broadcast either. StartsLogin is gone; the adapters now only report whether the destination is 1:1. A group still learns that a sign-in is pending — withholding the code must not turn into silence. BLOCKER — blocked runs leaked pending markers. The submit paths enqueue a marker BEFORE handing the job to the dispatcher, and a blocked run can never reach the clean terminal that clears it, so gating inside run() stranded one marker per refused message. The pending store is append-only: a poller or cron firing into an unauthorized deployment would grow it without bound and replay every marker on the next restart. The gate moved to the submit paths, ahead of the marker. ResumePending stays the deliberate exception — its marker came from a previous boot, so it is left untouched and replays once the sign-in lands. Also from the review: - Cancel now WAITS for the login goroutine to return instead of only cancelling its context. Shutdown's `defer auth.Cancel()` therefore actually reaps the polling child rather than racing it, and a /login issued right after a cancel starts fresh instead of being answered "already pending" with a dead code. - /login is dropped from the Telegram command menu where no interactive sign-in exists (Claude, an API-key backend, Codex billing). The handler stays registered, so typing it still explains itself. Tests now cover the cases the previous ones missed: they asserted status only when NO login was pending, which is exactly why they passed over the leak. New coverage drives a real pending login and checks every argument against both destination kinds, plus the marker count after repeated blocked submits. golangci-lint: 0 issues. Build, vet, full suite and -race all green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/vk/receiver.go | 23 +-- adapters/vk/receiver_test.go | 79 +++++++++- cmd/duck-vk/main.go | 1 + cmd/flock-telegram/codex_login_test.go | 202 ++++++++++++++++++++----- cmd/flock-telegram/commands_test.go | 6 +- cmd/flock-telegram/main.go | 54 +++---- core/chat/rungate.go | 33 ++-- core/chat/rungate_test.go | 43 ++++++ core/chat/service.go | 31 ++-- core/codexauth/codexauth.go | 112 ++++++++++---- core/codexauth/codexauth_test.go | 147 +++++++++++++++--- docs/codex-integration-plan.md | 12 +- 12 files changed, 577 insertions(+), 166 deletions(-) diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index 00d45a6..0b6dd1e 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -425,22 +425,16 @@ func (r *Receiver) dispatchReserved(ctx context.Context, name string, msg messag } } -// loginGroupChatText refuses to start a sign-in in a community conversation. -const loginGroupChatText = "Send /login in a direct message with me, not in a conversation.\n\n" + - "The sign-in reply carries a one-time code that authorizes an account for the whole bot, " + - "and everyone in this conversation would see it." - // dispatchLogin serves /login: start, report, or cancel the Codex device-code // sign-in. It stays available while the deployment is unauthorized — it is the // only way out of that state. The sender is already allow-list gated by // onMessageNew, which returns before any reserved command is dispatched. // -// Starting a sign-in is refused in a community conversation. The reply goes to -// the PEER, not the sender, so in a conversation the verification link and the -// one-time code would be readable by every participant, allow-listed or not — -// and whoever acts on the code first binds THEIR ChatGPT account to the bot, so -// every later run (and its history) would go through that account. status and -// cancel stay available everywhere: neither reveals a code. +// Whether a one-time code may be printed here is decided by codexauth from the +// Private flag, not by this adapter inspecting the arguments: /login status also +// re-shows a pending code, so an argument-based guard would let it straight past. +// A VK conversation has a peer id distinct from the sender's, which is exactly +// what makes it non-private. // // The sign-in outlives this call: Dispatch replies immediately and keeps working // in the background, delivering the link, the one-time code, and the verdict to @@ -448,12 +442,9 @@ const loginGroupChatText = "Send /login in a direct message with me, not in a co // long gone by the time a user finishes in a browser. func (r *Receiver) dispatchLogin(ctx context.Context, msg messageObject) { peerID := msg.PeerID - if isGroupPeer(peerID) && codexauth.StartsLogin(commandArgs(msg.Text)) { - r.notify(ctx, peerID, loginGroupChatText) - return - } sub := codexauth.Subscriber{ - ID: chatIDStr(peerID), + ID: chatIDStr(peerID), + Private: !isGroupPeer(peerID), Notify: func(text string) { sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginNotifyTimeout) defer cancel() diff --git a/adapters/vk/receiver_test.go b/adapters/vk/receiver_test.go index a362a9e..11eea74 100644 --- a/adapters/vk/receiver_test.go +++ b/adapters/vk/receiver_test.go @@ -5,10 +5,12 @@ import ( "context" "encoding/json" "errors" + "os" "path/filepath" "strings" "sync" "testing" + "time" "github.com/duckbugio/flock/core/agent" "github.com/duckbugio/flock/core/chat" @@ -706,6 +708,47 @@ func TestReceiverLoginWithoutManagerIsInert(t *testing.T) { } } +// pendingLoginManager returns a manager with a device login already in flight, +// its code issued, so a test can assert what each destination is allowed to see. +func pendingLoginManager(t *testing.T) *codexauth.Manager { + t.Helper() + m := codexauth.NewManager(codexauth.Config{ + Backend: codexauth.BackendCodex, + AuthMode: codexauth.AuthSubscription, + RequireAuth: true, + Home: t.TempDir(), + Bin: fakeCodexLogin(t), + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + t.Cleanup(func() { m.Cancel() }) + + got := make(chan string, 4) + m.Dispatch(context.Background(), "", codexauth.Subscriber{ + ID: "dm", Private: true, Notify: func(text string) { got <- text }, + }) + select { + case <-got: + case <-time.After(10 * time.Second): + t.Fatal("the fake login never issued a prompt") + } + return m +} + +// fakeCodexLogin writes a stand-in Codex CLI that prints the real device-auth +// prompt and then blocks, like the real one polling for the browser step. +func fakeCodexLogin(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "codex") + script := "#!/bin/sh\n" + + "printf '%s\\n' '1. Open this link' ' https://auth.openai.com/codex/device' \\\n" + + " '2. Enter this one-time code (expires in 15 minutes)' ' XER9-NWCA2'\n" + + "sleep 30\n" + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { //nolint:gosec // test fixture must be executable + t.Fatalf("write fake codex: %v", err) + } + return path +} + // TestReceiverLoginRefusedInConversation: the reply goes to the PEER, so in a // community conversation the verification link and one-time code would be // readable by every participant — and whoever acts on the code first binds THEIR @@ -726,19 +769,43 @@ func TestReceiverLoginRefusedInConversation(t *testing.T) { } } -// TestReceiverLoginStatusAllowedInConversation: status reveals no code, so it -// stays available everywhere — refusing it would just be noise. -func TestReceiverLoginStatusAllowedInConversation(t *testing.T) { +// TestReceiverLoginStatusInConversationHidesAPendingCode is the regression for +// the hole an argument-based guard left: /login status ALSO re-shows a pending +// one-time code, so refusing only the code-starting arguments let it into the +// conversation anyway — where anyone, allow-listed or not, could bind their own +// ChatGPT account to the bot. +func TestReceiverLoginStatusInConversationHidesAPendingCode(t *testing.T) { svc := &fakeService{} notices := &fakeNotice{} - r := unauthorizedCodexReceiver(t, svc, notices) + r := newTestReceiverWithAuth(svc, notices, pendingLoginManager(t)) r.dispatch(context.Background(), msgNewUpdate(t, messageObject{ FromID: 42, PeerID: 2000000001, Text: "/login status", })) - if len(notices.texts) != 1 || !strings.Contains(notices.texts[0], "NOT authorized") { - t.Errorf("notices = %v, want the status reply", notices.texts) + if len(notices.texts) != 1 { + t.Fatalf("notices = %v, want exactly one", notices.texts) + } + if strings.Contains(notices.texts[0], "XER9-NWCA2") || strings.Contains(notices.texts[0], "http") { + t.Errorf("the pending code or link reached a conversation: %q", notices.texts[0]) + } + if !strings.Contains(notices.texts[0], "in progress") { + t.Errorf("status = %q, want it to still report the pending sign-in", notices.texts[0]) + } +} + +// TestReceiverLoginStatusInDirectMessageShowsTheCode: the same command in a 1:1 +// chat must still re-show the code — that is what makes a lost message +// recoverable. +func TestReceiverLoginStatusInDirectMessageShowsTheCode(t *testing.T) { + svc := &fakeService{} + notices := &fakeNotice{} + r := newTestReceiverWithAuth(svc, notices, pendingLoginManager(t)) + + r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 42, Text: "/login status"})) + + if len(notices.texts) != 1 || !strings.Contains(notices.texts[0], "XER9-NWCA2") { + t.Errorf("notices = %v, want the pending code re-shown in a direct message", notices.texts) } } diff --git a/cmd/duck-vk/main.go b/cmd/duck-vk/main.go index bcddd8e..7f36eed 100644 --- a/cmd/duck-vk/main.go +++ b/cmd/duck-vk/main.go @@ -286,6 +286,7 @@ func run() int { logger.Warn("poller: bad chat id in branch", "chat_id", c.ChatID) continue } + //nolint:contextcheck // detached by design: the run (and its notice) must outlive this poll/replay context. svc.Inject(c.ChatID, formatPRComment(c)) } } diff --git a/cmd/flock-telegram/codex_login_test.go b/cmd/flock-telegram/codex_login_test.go index da0ccaf..569d31f 100644 --- a/cmd/flock-telegram/codex_login_test.go +++ b/cmd/flock-telegram/codex_login_test.go @@ -178,9 +178,8 @@ func groupCommandUpdate(text string, cmdLen int) *models.Update { // allow-listed or not — and whoever acts on the code first binds THEIR ChatGPT // account to the bot. The refusal must happen before any CLI is spawned. func TestLoginRefusedInGroupChat(t *testing.T) { - // A deliberately unusable CODEX_BIN: if the group check failed to short-circuit, - // the manager would try to run it and the test's assertions would still pass — - // so the manager is also asserted to have started nothing. + // A deliberately unusable CODEX_BIN: if the refusal failed to short-circuit, + // the manager would try to run it, which the status assertion below catches. home := t.TempDir() auth := codexauth.NewManager(codexauth.Config{ Backend: codexauth.BackendCodex, @@ -189,24 +188,8 @@ func TestLoginRefusedInGroupChat(t *testing.T) { Home: home, Bin: filepath.Join(home, "no-such-codex"), }) - cfg := config.Config{AllowedUsers: []int64{loginTestUserID}} - replies := &capturingHTTPClient{} - b, err := bot.New("123456:test-token", - bot.WithSkipGetMe(), - bot.WithNotAsyncHandlers(), - bot.WithHTTPClient(time.Minute, replies), - ) - if err != nil { - t.Fatalf("build test bot: %v", err) - } - for name, h := range reservedHandlers(cfg, nil, nil, auth) { - b.RegisterHandlerMatchFunc(commandMatch(name), h) - } - - b.ProcessUpdate(context.Background(), groupCommandUpdate("/login", len("/login"))) - - got := replies.seen() + got := groupCommandReplies(t, auth, "/login") if len(got) != 1 { t.Fatalf("replies = %v, want exactly one", got) } @@ -221,6 +204,111 @@ func TestLoginRefusedInGroupChat(t *testing.T) { } } +// TestLoginStatusInGroupHidesAPendingCode is the regression for the hole an +// argument-based guard left: /login status ALSO re-shows a pending one-time code, +// so refusing only the code-starting arguments let it into the group anyway. +func TestLoginStatusInGroupHidesAPendingCode(t *testing.T) { + auth := pendingLoginManager(t) + + got := groupCommandReplies(t, auth, "/login status") + if len(got) != 1 { + t.Fatalf("replies = %v, want exactly one", got) + } + if strings.Contains(got[0], "XER9-NWCA2") || strings.Contains(got[0], "http") { + t.Errorf("the pending code or link reached a group: %q", got[0]) + } + if !strings.Contains(got[0], "in progress") { + t.Errorf("status = %q, want it to still report the pending sign-in", got[0]) + } +} + +// TestLoginStatusInPrivateShowsThePendingCode: the same command in a direct +// message must still re-show the code — that is what makes a lost message +// recoverable. +func TestLoginStatusInPrivateShowsThePendingCode(t *testing.T) { + auth := pendingLoginManager(t) + cfg := config.Config{AllowedUsers: []int64{loginTestUserID}} + replies := &capturingHTTPClient{} + b := commandBot(t, cfg, auth, replies) + + b.ProcessUpdate(context.Background(), + privateCommandUpdate(loginTestUserID, loginTestChatID, "/login status", len("/login"))) + + got := replies.seen() + if len(got) != 1 || !strings.Contains(got[0], "XER9-NWCA2") { + t.Errorf("replies = %v, want the pending code re-shown in a direct message", got) + } +} + +// pendingLoginManager returns a manager with a device login already in flight, +// its code issued, so a test can assert what each destination is allowed to see. +func pendingLoginManager(t *testing.T) *codexauth.Manager { + t.Helper() + m := codexauth.NewManager(codexauth.Config{ + Backend: codexauth.BackendCodex, + AuthMode: codexauth.AuthSubscription, + RequireAuth: true, + Home: t.TempDir(), + Bin: fakeCodexLogin(t), + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + t.Cleanup(func() { m.Cancel() }) + + issued := make(chan string, 4) + m.Dispatch(context.Background(), "", codexauth.Subscriber{ + ID: "dm", Private: true, Notify: func(text string) { issued <- text }, + }) + select { + case <-issued: + case <-time.After(10 * time.Second): + t.Fatal("the fake login never issued a prompt") + } + return m +} + +// fakeCodexLogin writes a stand-in Codex CLI that prints the real device-auth +// prompt and then blocks, like the real one polling for the browser step. +func fakeCodexLogin(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "codex") + script := "#!/bin/sh\n" + + "printf '%s\\n' '1. Open this link' ' https://auth.openai.com/codex/device' \\\n" + + " '2. Enter this one-time code (expires in 15 minutes)' ' XER9-NWCA2'\n" + + "sleep 30\n" + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { //nolint:gosec // test fixture must be executable + t.Fatalf("write fake codex: %v", err) + } + return path +} + +// commandBot builds a bot with the reserved handlers registered and every API +// call answered locally by http. +func commandBot(t *testing.T, cfg config.Config, auth *codexauth.Manager, client bot.HttpClient) *bot.Bot { + t.Helper() + b, err := bot.New("123456:test-token", + bot.WithSkipGetMe(), + bot.WithNotAsyncHandlers(), + bot.WithHTTPClient(time.Minute, client), + ) + if err != nil { + t.Fatalf("build test bot: %v", err) + } + for name, h := range reservedHandlers(cfg, nil, nil, auth) { + b.RegisterHandlerMatchFunc(commandMatch(name), h) + } + return b +} + +// groupCommandReplies runs text as a group slash command and returns what the +// bot replied. +func groupCommandReplies(t *testing.T, auth *codexauth.Manager, text string) []string { + t.Helper() + replies := &capturingHTTPClient{} + b := commandBot(t, config.Config{AllowedUsers: []int64{loginTestUserID}}, auth, replies) + b.ProcessUpdate(context.Background(), groupCommandUpdate(text, len("/login"))) + return replies.seen() +} + // capturingHTTPClient answers every Bot API call locally like stubHTTPClient, // but records the raw payload of each sendMessage so a test can assert what the // user was actually told (the encoding varies, so assertions substring-match). @@ -249,29 +337,12 @@ func (c *capturingHTTPClient) seen() []string { return append([]string(nil), c.texts...) } -// TestLoginStatusAllowedInGroupChat: status reveals no code, so refusing it -// everywhere would be noise rather than protection. It must answer with the real -// state, not the direct-message refusal. -func TestLoginStatusAllowedInGroupChat(t *testing.T) { +// TestLoginStatusInGroupWithNothingPending: with no sign-in in flight there is +// no code to withhold, so the group is simply told the state. +func TestLoginStatusInGroupWithNothingPending(t *testing.T) { auth, _ := unauthorizedCodexAuth(t) - cfg := config.Config{AllowedUsers: []int64{loginTestUserID}} - http := &capturingHTTPClient{} - b, err := bot.New("123456:test-token", - bot.WithSkipGetMe(), - bot.WithNotAsyncHandlers(), - bot.WithHTTPClient(time.Minute, http), - ) - if err != nil { - t.Fatalf("build test bot: %v", err) - } - for name, h := range reservedHandlers(cfg, nil, nil, auth) { - b.RegisterHandlerMatchFunc(commandMatch(name), h) - } - - b.ProcessUpdate(context.Background(), groupCommandUpdate("/login status", len("/login"))) - - got := http.seen() + got := groupCommandReplies(t, auth, "/login status") if len(got) != 1 { t.Fatalf("replies = %v, want exactly one", got) } @@ -279,3 +350,52 @@ func TestLoginStatusAllowedInGroupChat(t *testing.T) { t.Errorf("reply = %q, want the status, not a refusal", got[0]) } } + +// codexAuthForMenu is a manager for a Codex subscription deploy, i.e. one where +// every reserved command — including /login — applies. +var codexAuthForMenu = codexauth.NewManager(codexauth.Config{ + Backend: codexauth.BackendCodex, + AuthMode: codexauth.AuthSubscription, +}) + +// TestMenuOmitsLoginWithoutAnInteractiveSignIn: on Claude, an API-key backend or +// Codex billing there is no sign-in to complete, so advertising /login in the +// menu would only lead a user to a command that answers "not needed". The +// handler stays registered, so typing it still gets that answer. +func TestMenuOmitsLoginWithoutAnInteractiveSignIn(t *testing.T) { + for _, tt := range []struct { + name string + auth *codexauth.Manager + }{ + {"claude", codexauth.NewManager(codexauth.Config{Backend: "claude"})}, + {"codex billing", codexauth.NewManager(codexauth.Config{ + Backend: codexauth.BackendCodex, AuthMode: codexauth.AuthBilling, + })}, + {"no manager", nil}, + } { + t.Run(tt.name, func(t *testing.T) { + for _, c := range reservedBotCommands(tt.auth) { + if c.Command == "login" { + t.Errorf("/login is published in the menu on a deployment with no sign-in") + } + } + if got, want := len(reservedBotCommands(tt.auth)), len(chat.ReservedCommands)-1; got != want { + t.Errorf("menu has %d entries, want %d (everything but /login)", got, want) + } + }) + } +} + +// TestMenuKeepsLoginOnCodexSubscription is the other half: where the sign-in is +// real, the command must be advertised. +func TestMenuKeepsLoginOnCodexSubscription(t *testing.T) { + found := false + for _, c := range reservedBotCommands(codexAuthForMenu) { + if c.Command == "login" { + found = true + } + } + if !found { + t.Error("/login is missing from the menu on a Codex subscription deployment") + } +} diff --git a/cmd/flock-telegram/commands_test.go b/cmd/flock-telegram/commands_test.go index 68f9e36..de0c279 100644 --- a/cmd/flock-telegram/commands_test.go +++ b/cmd/flock-telegram/commands_test.go @@ -72,9 +72,11 @@ func TestCommandSenderAllowList(t *testing.T) { // TestReservedBotCommandsFromCanonicalSet asserts the published Telegram command // menu is built one-for-one from core/chat.ReservedCommands (the single source of // truth) — same names, same order, same descriptions — so the menu can never -// drift from the commands the handlers route on. +// drift from the commands the handlers route on. The manager here is a Codex +// subscription one, for which every reserved command applies; see +// TestMenuOmitsLoginWithoutAnInteractiveSignIn for the one conditional entry. func TestReservedBotCommandsFromCanonicalSet(t *testing.T) { - got := reservedBotCommands() + got := reservedBotCommands(codexAuthForMenu) if len(got) != len(chat.ReservedCommands) { t.Fatalf("reservedBotCommands has %d entries, want %d", len(got), len(chat.ReservedCommands)) } diff --git a/cmd/flock-telegram/main.go b/cmd/flock-telegram/main.go index 0210fc1..f225a37 100644 --- a/cmd/flock-telegram/main.go +++ b/cmd/flock-telegram/main.go @@ -349,7 +349,7 @@ func run() int { // source the handlers above route on). Native Claude commands (/loop, /security-review, …) // are intentionally NOT listed — they pass through as free text. Best-effort: // a failure here only means an empty/stale menu, so log and continue. - if _, err := b.SetMyCommands(ctx, &bot.SetMyCommandsParams{Commands: reservedBotCommands()}); err != nil { + if _, err := b.SetMyCommands(ctx, &bot.SetMyCommandsParams{Commands: reservedBotCommands(auth)}); err != nil { logger.Warn("set telegram command menu", "error", err) } @@ -379,6 +379,7 @@ func run() int { logger.Warn("poller: bad chat id in branch", "chat_id", c.ChatID) continue } + //nolint:contextcheck // detached by design: the run (and its notice) must outlive this poll/replay context. svc.Inject(c.ChatID, formatPRComment(c)) } } @@ -965,9 +966,16 @@ func starHandler(cfg config.Config, svc *chat.Service) bot.HandlerFunc { // reservedBotCommands maps the canonical reserved set (core/chat) to the Telegram // command-menu model published via SetMyCommands. It is the single place the menu // is built, so the menu can never drift from the commands the handlers route on. -func reservedBotCommands() []models.BotCommand { +func reservedBotCommands(auth *codexauth.Manager) []models.BotCommand { cmds := make([]models.BotCommand, 0, len(chat.ReservedCommands)) for _, c := range chat.ReservedCommands { + // /login is meaningless on a deployment with no interactive sign-in (Claude, + // an API-key backend, Codex billing): it would sit in the menu only to answer + // that it is not needed. The handler stays registered either way, so a user + // who types it still gets that answer. + if c.Name == loginCommand && !auth.Applicable() { + continue + } cmds = append(cmds, models.BotCommand{Command: c.Name, Description: c.Description}) } return cmds @@ -985,13 +993,13 @@ func reservedHandlers( cfg config.Config, svc *chat.Service, sched *schedule.Manager, auth *codexauth.Manager, ) map[string]bot.HandlerFunc { return map[string]bot.HandlerFunc{ - "start": startHandler(cfg), - "help": helpHandler(cfg), - "new": newHandler(cfg, svc), - "stop": stopCommandHandler(cfg, svc), - "schedule": scheduleHandler(cfg, sched), - "goal": goalHandler(cfg, svc), - "login": loginHandler(cfg, auth), + "start": startHandler(cfg), + "help": helpHandler(cfg), + "new": newHandler(cfg, svc), + "stop": stopCommandHandler(cfg, svc), + "schedule": scheduleHandler(cfg, sched), + "goal": goalHandler(cfg, svc), + loginCommand: loginHandler(cfg, auth), } } @@ -1000,6 +1008,10 @@ func reservedHandlers( // deployment is unauthorized — it is the only way out of that state — and, like // every reserved handler, is allow-list gated and never starts a run. // +// Whether a one-time code may be printed here is decided by codexauth from the +// Private flag, not by this handler inspecting the arguments: /login status also +// re-shows a pending code, so an argument-based guard would let it straight past. +// // The device flow outlives this handler: Dispatch returns an immediate reply and // keeps working in the background, delivering the link, the one-time code, and // the final verdict through the notify callback. That callback therefore builds @@ -1011,36 +1023,25 @@ func loginHandler(cfg config.Config, auth *codexauth.Manager) bot.HandlerFunc { if !ok { return } - args := commandArgs(update.Message.Text) - if update.Message.Chat.Type != models.ChatTypePrivate && codexauth.StartsLogin(args) { - sendCommandReply(ctx, b, chatID, loginGroupChatText) - return - } sub := codexauth.Subscriber{ - ID: chatIDStr(chatID), + ID: chatIDStr(chatID), + Private: update.Message.Chat.Type == models.ChatTypePrivate, Notify: func(text string) { sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginNotifyTimeout) defer cancel() sendCommandReply(sendCtx, b, chatID, text) }, } - sendCommandReply(ctx, b, chatID, auth.Dispatch(ctx, args, sub)) + sendCommandReply(ctx, b, chatID, auth.Dispatch(ctx, commandArgs(update.Message.Text), sub)) } } -// loginGroupChatText refuses to start a sign-in in a group chat. The reply goes -// to the CHAT, not the sender, so in a group the verification link and one-time -// code would be readable by every member, allow-listed or not — and whoever acts -// on the code first binds THEIR ChatGPT account to the bot, so every later run -// (and its history) would go through that account. status and cancel stay -// available everywhere: neither reveals a code. -const loginGroupChatText = "Send /login in a direct message with me, not in a group.\n\n" + - "The sign-in reply carries a one-time code that authorizes an account for the whole bot, " + - "and everyone in this group would see it." - // loginNotifyTimeout bounds one background login notice delivery. const loginNotifyTimeout = 30 * time.Second +// loginCommand is the reserved command name the sign-in flow is published under. +const loginCommand = "login" + // commandMatch routes a message whose leading bot command is name, tolerating // the @botname suffix Telegram adds in groups (/new@duck_bot). It only routes; // the allow-list gate lives in each handler via commandSender. @@ -1256,6 +1257,7 @@ func resumePending( } } } + //nolint:contextcheck // detached by design: the run (and its notice) must outlive this poll/replay context. svc.ResumePending(chatID, m) } } diff --git a/core/chat/rungate.go b/core/chat/rungate.go index a1dbcd7..46eb19c 100644 --- a/core/chat/rungate.go +++ b/core/chat/rungate.go @@ -6,29 +6,39 @@ import "context" // It exists for one case today: a Codex subscription deployment that has not // completed its interactive sign-in yet (core/codexauth.Manager satisfies it). // -// The gate lives HERE, at the single point every run passes through, rather than +// The gate lives HERE, at the submit paths every run enters through, rather than // only in the adapters' message paths. Runs reach the provider from five places — // a user message, a poller-injected PR comment, a cron fire, a workspace -// follow-up, and the restart replay of an interrupted run — and only the first -// of those goes through an adapter. Gating in the adapters alone would let the -// other four march into a CLI that cannot authenticate, once per schedule tick, -// with nothing user-visible to explain it. +// follow-up and the restart replay of an interrupted run — and only the first of +// those goes through an adapter. Gating in the adapters alone would let the other +// four march into a CLI that cannot authenticate, once per schedule tick, with +// nothing user-visible to explain it. type RunGate interface { // BlockedNotice returns the user-facing explanation and true when runs must // be blocked, or ("", false) when they may proceed. BlockedNotice() (string, bool) } -// blockRun reports whether this run must be dropped because the provider is -// unauthorized, and tells the chat once why. +// blockSubmit reports whether a submission must be refused because the provider +// is unauthorized, and tells the chat once why. +// +// It is called at SUBMIT time, before a pending marker would be enqueued. That +// ordering is the point: blocking later, inside the run, would leave behind a +// marker for work that never started — and since the pending store is append-only +// and a blocked run can never reach the clean terminal that clears it, a poller +// or cron firing into an unauthorized deployment would accumulate markers without +// bound and replay every one of them on the next restart. Refusing before the +// marker exists means there is nothing to leak. ResumePending is the deliberate +// exception: its marker came from a previous boot, so it is left untouched and +// replays once the sign-in lands. // // The notice is sent at most ONCE per chat per unauthorized period: background // sources are recurring (a five-minute cron would otherwise post a notice every // five minutes), and the adapters already answer a user's own message directly. -// The drop is logged every time, so the operator sees the full picture in the +// The refusal is logged every time, so the operator sees the full picture in the // logs while the chat stays readable. The counter resets as soon as the provider // is authorized again, so a later lapse is announced afresh. -func (s *Service) blockRun(ctx context.Context, chatID ChatID) bool { +func (s *Service) blockSubmit(ctx context.Context, chatID ChatID) bool { if s.auth == nil { return false } @@ -40,7 +50,7 @@ func (s *Service) blockRun(ctx context.Context, chatID ChatID) bool { return false } - s.log.Warn("provider is not authorized — dropping run", "chat_id", chatID) + s.log.Warn("provider is not authorized — refusing run", "chat_id", chatID) s.mu.Lock() first := !s.authNotified[chatID] @@ -48,6 +58,9 @@ func (s *Service) blockRun(ctx context.Context, chatID ChatID) bool { s.mu.Unlock() if first && notice != "" { + // Sent synchronously on the caller's own goroutine, so a request context is + // still alive where there is one; the background sources pass context + // Background because they have none. if _, err := s.chat.Send(ctx, chatID, notice, "", true); err != nil { s.log.Error("send unauthorized notice", "chat_id", chatID, "error", err) } diff --git a/core/chat/rungate_test.go b/core/chat/rungate_test.go index 5556b90..855fe19 100644 --- a/core/chat/rungate_test.go +++ b/core/chat/rungate_test.go @@ -189,3 +189,46 @@ func TestGateAllowsRunsWhenAuthorized(t *testing.T) { }) } } + +// TestBlockedSubmitLeavesNoPendingMarker is the marker-leak regression. The +// submit paths enqueue a marker BEFORE handing the job to the dispatcher, and a +// blocked run can never reach the clean terminal that clears it — so blocking +// inside the run would strand one marker per refused message. The pending store +// is append-only, and a poller or cron firing into an unauthorized deployment +// would grow it without bound and replay every marker on the next restart. +func TestBlockedSubmitLeavesNoPendingMarker(t *testing.T) { + tests := []struct { + name string + start func(s *Service) + }{ + {"user message", func(s *Service) { s.Handle(context.Background(), testChatID, 7, "m1", "build it") }}, + {"edited message", func(s *Service) { s.HandleEdit(context.Background(), testChatID, 7, "m1", "edited") }}, + {"poller relay", func(s *Service) { s.Inject(testChatID, "a reviewer commented") }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, p, gate := newFakeChat(), newFakePending(), blockingGate() + s := gatedService(t, &countingRunner{}, c, gate, p) + + for range 5 { + tt.start(s) + } + waitUntil(t, func() bool { return gate.calls() >= 5 }) + + if got := p.count(); got != 0 { + t.Errorf("five blocked submits left %d pending markers, want 0", got) + } + }) + } +} + +// TestBlockedScheduledFireIsReportedDropped: the scheduler reads the bool to know +// the fire did not take, exactly as it does for the cost cap. +func TestBlockedScheduledFireIsReportedDropped(t *testing.T) { + c, gate := newFakeChat(), blockingGate() + s := gatedService(t, &countingRunner{}, c, gate, newFakePending()) + + if s.InjectScheduled(testChatID, "nightly", 7) { + t.Error("InjectScheduled reported the fire enqueued while unauthorized") + } +} diff --git a/core/chat/service.go b/core/chat/service.go index 8bb756f..f903344 100644 --- a/core/chat/service.go +++ b/core/chat/service.go @@ -262,8 +262,11 @@ func (s *Service) Handle(_ context.Context, chatID ChatID, userID int64, msgID M // behaves exactly like Handle (trailing-arg text path). Edit-tracking and // dispatch semantics are identical to Handle. func (s *Service) HandleMedia( - _ context.Context, chatID ChatID, userID int64, msgID MessageID, prompt string, images []agent.ImageInput, + ctx context.Context, chatID ChatID, userID int64, msgID MessageID, prompt string, images []agent.ImageInput, ) { + if s.blockSubmit(ctx, chatID) { + return + } s.mu.Lock() s.lastMsg[chatID] = msgID // A real user message opens a fresh chapter: the post-run verification retry @@ -289,6 +292,9 @@ func (s *Service) HandleMedia( // allow-listed user holds); these synthetic relays are rare and are not rate-/ // cost-gated on the inbound path. func (s *Service) Inject(chatID ChatID, prompt string) { + if s.blockSubmit(context.Background(), chatID) { + return + } // Enqueue before Submit so a poller-injected run is captured for auto-resume // too (it should resume if killed mid-flight, same as a user message). id := s.enqueuePending(chatID, prompt) @@ -314,6 +320,9 @@ func (s *Service) Inject(chatID ChatID, prompt string) { // and true when the run was enqueued. Inject, by contrast, runs as the sentinel // user 0, is durable (enqueues a marker), and blocks on a full buffer. func (s *Service) InjectScheduled(chatID ChatID, prompt string, userID int64) bool { + if s.blockSubmit(context.Background(), chatID) { + return false + } if s.costs != nil && !s.costs.Allowed(userID, s.costCapUSD) { return false } @@ -330,6 +339,12 @@ func (s *Service) InjectScheduled(chatID ChatID, prompt string, userID int64) bo // It deliberately does not touch edit-tracking state (like Inject), so replay // never interferes with a real user's supersede logic. func (s *Service) ResumePending(chatID ChatID, m pending.Marker) { + // The marker is deliberately left in place: it was not created here, and an + // interrupted run must not be lost to the outage that is blocking it. It + // replays after the sign-in, on the next restart. + if s.blockSubmit(context.Background(), chatID) { + return + } s.dispatch.Submit(chatID, func(ctx context.Context) { s.run(ctx, chatID, 0, m.Prompt, nil, m.ID) }) @@ -349,8 +364,11 @@ func (s *Service) HandleEdit(ctx context.Context, chatID ChatID, userID int64, m // HandleEditMedia is HandleEdit with optional image attachments (see // HandleMedia). An empty images slice behaves exactly like HandleEdit. func (s *Service) HandleEditMedia( - _ context.Context, chatID ChatID, userID int64, msgID MessageID, prompt string, images []agent.ImageInput, + ctx context.Context, chatID ChatID, userID int64, msgID MessageID, prompt string, images []agent.ImageInput, ) { + if s.blockSubmit(ctx, chatID) { + return + } s.mu.Lock() last, ok := s.lastMsg[chatID] supersede := ok && last == msgID @@ -413,15 +431,6 @@ func (s *Service) Stop(runID string) bool { func (s *Service) run( ctx context.Context, chatID ChatID, userID int64, prompt string, images []agent.ImageInput, markerID string, ) { - // The provider gate, at the single point EVERY run passes through: a user - // message, a poller-injected PR comment, a cron fire, a workspace follow-up and - // the restart replay all land here. A blocked run is dropped before any work — - // and before the pending marker would be cleared, so an interrupted run stays - // queued for after the sign-in instead of being lost to it. - if s.blockRun(ctx, chatID) { - return - } - runID := strconv.FormatUint(s.runSeq.Add(1), 10) s.mu.Lock() s.runChat[runID] = chatID diff --git a/core/codexauth/codexauth.go b/core/codexauth/codexauth.go index da27a63..5fc804f 100644 --- a/core/codexauth/codexauth.go +++ b/core/codexauth/codexauth.go @@ -135,6 +135,7 @@ type Manager struct { last Prompt hasLast bool subs []Subscriber + done chan struct{} // closed when the in-flight login goroutine has returned } // NewManager returns a Manager for cfg. @@ -213,8 +214,15 @@ func (m *Manager) BlockedNotice() (string, bool) { const NoLoginNeededText = "No interactive login is needed on this deployment: " + "the AI provider authenticates from its configured credentials." -// StatusText describes the current auth state without ever revealing a secret. -func (m *Manager) StatusText() string { +// StatusText describes the current auth state for a PRIVATE destination. It +// never reveals a stored secret, but it does re-show a pending sign-in's +// one-time code, which is why the destination matters — see statusText. +func (m *Manager) StatusText() string { return m.statusText(true) } + +// statusText describes the current auth state. When the destination is not +// private the pending code is withheld: /login status is otherwise a way to +// print it into a group, straight past the guard on starting a sign-in there. +func (m *Manager) statusText(private bool) string { if !m.Applicable() { return m.notApplicableText() } @@ -223,6 +231,8 @@ func (m *Manager) StatusText() string { m.mu.Unlock() switch { + case running && hasLast && !private: + return pendingElsewhereText case running && hasLast: return "Codex device login is in progress — finish it in the browser:\n\n" + promptText(last) case running: @@ -258,6 +268,15 @@ func (m *Manager) notApplicableText() string { ", which authenticates from its configured credentials." } +// LoginPrivateOnlyText refuses to start a sign-in outside a 1:1 chat. +const LoginPrivateOnlyText = "Send /login in a direct message with me, not here.\n\n" + + "The reply carries a one-time code that authorizes an account for the whole bot, " + + "and everyone in this chat would see it." + +// pendingElsewhereText reports a pending sign-in without reprinting its code. +const pendingElsewhereText = "A Codex sign-in is in progress. Its link and one-time code went to the " + + "direct message that started it — send /login there to see them again." + // LoginUsage is the /login help. const LoginUsage = "Usage:\n" + "/login — start the Codex browser sign-in (or re-show the pending code)\n" + @@ -265,14 +284,26 @@ const LoginUsage = "Usage:\n" + "/login cancel — abort a pending sign-in\n" + "/login force — sign in again even if already authorized" -// Subscriber is where a pending login's background notices are delivered. ID -// identifies the destination (a chat id) so the same destination asking twice is -// collapsed to one subscription rather than being told everything twice; Notify -// does the delivery and may be called minutes later, from another goroutine, so -// it must build its own context rather than close over the update's. +// Subscriber is where a /login reply and a pending login's background notices +// are delivered. +// +// Private is the security-critical field: the one-time code authorizes an +// account for the WHOLE bot, so whoever acts on it first binds their ChatGPT +// account to it and every later run (and its history) goes through that account. +// A code may therefore only ever reach a 1:1 destination. The judgement lives +// HERE rather than in each adapter because it cannot be derived from the command +// arguments: /login status also prints the pending code, so an argument-based +// guard silently misses it. Manager redacts instead, on every path that could +// carry a code. +// +// ID identifies the destination so the same one asking twice is collapsed to a +// single subscription rather than being told everything twice. Notify does the +// delivery and may be called minutes later, from another goroutine, so it must +// build its own context rather than close over the update's. type Subscriber struct { - ID string - Notify func(string) + ID string + Private bool + Notify func(string) } // deliver sends text when the subscriber can receive it. @@ -282,20 +313,6 @@ func (s Subscriber) deliver(text string) { } } -// StartsLogin reports whether these /login arguments would begin a sign-in, as -// opposed to merely reporting or cancelling one. Adapters use it to refuse the -// code-revealing forms in a group chat while leaving status and cancel available -// everywhere; it shares Dispatch's switch so the two can never disagree about -// which arguments print a code. -func StartsLogin(args string) bool { - switch normalizeArgs(args) { - case "", "force", "again", "relogin": - return true - default: - return false - } -} - // normalizeArgs canonicalizes a /login argument for comparison. func normalizeArgs(args string) string { return strings.ToLower(strings.TrimSpace(args)) } @@ -323,7 +340,7 @@ func (m *Manager) Dispatch(base context.Context, args string, sub Subscriber) st case "force", "again", "relogin": return m.start(base, true, sub) case "status": - return m.StatusText() + return m.statusText(sub.Private) case "cancel", "abort", "stop": if m.Cancel() { return "Cancelled the pending Codex login." @@ -340,6 +357,12 @@ func (m *Manager) Dispatch(base context.Context, args string, sub Subscriber) st // pending code is re-shown, never replaced) or Codex is already authorized and // force was not asked for. func (m *Manager) start(base context.Context, force bool, sub Subscriber) string { + // Refused before anything else: every reply below can carry the one-time code, + // and subscribing a group destination would also feed it the code when the + // prompt is broadcast. + if !sub.Private { + return LoginPrivateOnlyText + } m.mu.Lock() if m.running { m.subscribeLocked(sub) @@ -361,22 +384,25 @@ func (m *Manager) start(base context.Context, force bool, sub Subscriber) string // context.WithoutCancel: the login must survive the update whose handler // started it. The timeout is the real bound. ctx, cancel := context.WithTimeout(context.WithoutCancel(base), m.cfg.timeout()) + done := make(chan struct{}) m.running = true m.cancel = cancel + m.done = done m.hasLast = false m.last = Prompt{} m.subs = nil m.subscribeLocked(sub) m.mu.Unlock() - go m.run(ctx, cancel) + go m.run(ctx, cancel, done) return "Starting the Codex sign-in — the link and one-time code arrive in a moment." } // subscribeLocked adds sub to the pending login's notice list, collapsing a -// destination that is already subscribed. The caller must hold m.mu. +// destination that is already subscribed. The caller must hold m.mu. Only +// private destinations subscribe: the broadcast carries the one-time code. func (m *Manager) subscribeLocked(sub Subscriber) { - if sub.Notify == nil { + if sub.Notify == nil || !sub.Private { return } for _, existing := range m.subs { @@ -399,7 +425,9 @@ func (m *Manager) broadcast(text string) { } // run drives one login attempt and reports its result to every subscriber. -func (m *Manager) run(ctx context.Context, cancel context.CancelFunc) { +// Closing done is what lets Cancel wait for the child to actually be gone. +func (m *Manager) run(ctx context.Context, cancel context.CancelFunc, done chan struct{}) { + defer close(done) defer cancel() log := m.cfg.logger() @@ -414,6 +442,7 @@ func (m *Manager) run(ctx context.Context, cancel context.CancelFunc) { m.mu.Lock() m.running = false m.cancel = nil + m.done = nil m.mu.Unlock() if err == nil { @@ -425,18 +454,41 @@ func (m *Manager) run(ctx context.Context, cancel context.CancelFunc) { m.broadcast(failureText(err)) } +// cancelDrain bounds how long Cancel waits for the login child to be reaped. +// Login signals the process GROUP and escalates to SIGKILL after killGrace, so +// the real wait is milliseconds; the bound only stops shutdown from hanging on a +// pathological child. +const cancelDrain = 5 * time.Second + // Cancel aborts a pending login, reporting whether there was one. +// +// It WAITS for the login goroutine to return rather than just cancelling its +// context. Two things depend on that. On shutdown, `defer auth.Cancel()` is the +// only thing that ends a login deliberately detached from every request context +// — returning before the child is signalled and reaped would leave an orphaned +// polling process, which is exactly what the defer exists to prevent. And a +// /login issued right after a cancel must not be answered with "already pending" +// plus a code that is already dead. func (m *Manager) Cancel() bool { if m == nil { return false } m.mu.Lock() - defer m.mu.Unlock() if !m.running || m.cancel == nil { + m.mu.Unlock() return false } - m.cancel() + cancel, done := m.cancel, m.done m.cancel = nil + m.mu.Unlock() + + cancel() + if done != nil { + select { + case <-done: + case <-time.After(cancelDrain): + } + } return true } diff --git a/core/codexauth/codexauth_test.go b/core/codexauth/codexauth_test.go index 936acd8..bbfb2c5 100644 --- a/core/codexauth/codexauth_test.go +++ b/core/codexauth/codexauth_test.go @@ -25,10 +25,15 @@ type collector struct { func newCollector() *collector { return &collector{ch: make(chan string, 8)} } +// dmSub is a private (1:1) destination — the only kind allowed to see a code. +var dmSub = Subscriber{ID: "dm", Private: true} + func (c *collector) notify(text string) { c.ch <- text } // sub is the collector as a Subscriber for destination id. -func (c *collector) sub(id string) Subscriber { return Subscriber{ID: id, Notify: c.notify} } +func (c *collector) sub(id string) Subscriber { + return Subscriber{ID: id, Private: true, Notify: c.notify} +} // next returns the next notice, failing the test if none arrives in time. func (c *collector) next(t *testing.T) string { @@ -154,7 +159,7 @@ func TestNilManagerIsInert(t *testing.T) { if m.Cancel() { t.Error("nil Manager cancelled a login") } - if got := m.Dispatch(context.Background(), "", Subscriber{}); got != NoLoginNeededText { + if got := m.Dispatch(context.Background(), "", Subscriber{ID: "dm", Private: true}); got != NoLoginNeededText { t.Errorf("nil Manager Dispatch = %q, want NoLoginNeededText", got) } } @@ -163,11 +168,12 @@ func TestNilManagerIsInert(t *testing.T) { // itself instead of running anything. func TestDispatchNotApplicable(t *testing.T) { claude := NewManager(Config{Backend: "claude"}) - if got := claude.Dispatch(context.Background(), "", Subscriber{}); !strings.Contains(got, "claude") { + if got := claude.Dispatch(context.Background(), "", Subscriber{ID: "dm", Private: true}); !strings.Contains(got, "claude") { t.Errorf("Dispatch = %q, want it to name the configured backend", got) } billing := NewManager(Config{Backend: BackendCodex, AuthMode: AuthBilling}) - if got := billing.Dispatch(context.Background(), "", Subscriber{}); !strings.Contains(got, "CODEX_API_KEY") { + dm := Subscriber{ID: "dm", Private: true} + if got := billing.Dispatch(context.Background(), "", dm); !strings.Contains(got, "CODEX_API_KEY") { t.Errorf("Dispatch = %q, want the billing-mode explanation", got) } } @@ -270,7 +276,7 @@ func TestDispatchCancel(t *testing.T) { Env: []string{"PATH=" + os.Getenv("PATH")}, }) - if got := m.Dispatch(context.Background(), "cancel", Subscriber{}); !strings.Contains(got, "No Codex login is pending") { + if got := m.Dispatch(context.Background(), "cancel", dmSub); !strings.Contains(got, "No Codex login is pending") { t.Errorf("cancel with nothing pending = %q", got) } @@ -294,7 +300,7 @@ func TestDispatchAlreadyAuthorized(t *testing.T) { writeAuthFile(t, home) m := NewManager(Config{Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: home}) - got := m.Dispatch(context.Background(), "", Subscriber{}) + got := m.Dispatch(context.Background(), "", Subscriber{ID: "dm", Private: true}) if !strings.Contains(got, "authorized") || !strings.Contains(got, "/login force") { t.Errorf("Dispatch = %q, want the authorized state plus the force hint", got) } @@ -304,13 +310,13 @@ func TestDispatchAlreadyAuthorized(t *testing.T) { func TestDispatchStatusAndUsage(t *testing.T) { m := NewManager(Config{Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: t.TempDir()}) - if got := m.Dispatch(context.Background(), "status", Subscriber{}); !strings.Contains(got, "NOT authorized") { + if got := m.Dispatch(context.Background(), "status", dmSub); !strings.Contains(got, "NOT authorized") { t.Errorf("status = %q, want the unauthorized state", got) } - if got := m.Dispatch(context.Background(), "help", Subscriber{}); got != LoginUsage { + if got := m.Dispatch(context.Background(), "help", Subscriber{ID: "dm", Private: true}); got != LoginUsage { t.Errorf("help = %q, want LoginUsage", got) } - if got := m.Dispatch(context.Background(), "wat", Subscriber{}); !strings.Contains(got, "Unknown /login argument") { + if got := m.Dispatch(context.Background(), "wat", dmSub); !strings.Contains(got, "Unknown /login argument") { t.Errorf("unknown arg = %q, want the usage hint", got) } } @@ -404,19 +410,120 @@ func TestSubscriptionLoginDropsInheritedAPIKey(t *testing.T) { } } -// TestStartsLogin pins which arguments print a code — the adapters gate group -// chats on exactly this, so it must agree with Dispatch's own switch. -func TestStartsLogin(t *testing.T) { - for _, args := range []string{"", " ", "force", "FORCE", "again", "relogin"} { - if !StartsLogin(args) { - t.Errorf("StartsLogin(%q) = false, want true (this form reveals a code)", args) - } +// TestNoCodeReachesANonPrivateDestination is the regression for the guard that +// an argument-based check missed: /login status ALSO prints a pending code, so +// the decision has to key off the destination, not the arguments. A group asking +// anything at all must never see the link or the code. +func TestNoCodeReachesANonPrivateDestination(t *testing.T) { + bin := fakeCodex(t, printBanner+"sleep 30\n") + m := NewManager(Config{ + Backend: BackendCodex, + AuthMode: AuthSubscription, + RequireAuth: true, + Bin: bin, + Home: t.TempDir(), + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + t.Cleanup(func() { m.Cancel() }) + + // A sign-in is pending, started from a direct message. + dm := newCollector() + m.Dispatch(context.Background(), "", Subscriber{ID: "dm", Private: true, Notify: dm.notify}) + if p := dm.next(t); !strings.Contains(p, deviceCode) { + t.Fatalf("the private starter did not get the code: %q", p) } - for _, args := range []string{"status", "cancel", "abort", "help", "nonsense"} { - if StartsLogin(args) { - t.Errorf("StartsLogin(%q) = true, want false", args) - } + + group := Subscriber{ID: "group", Private: false, Notify: func(string) {}} + for _, args := range []string{"", "status", "force", "again"} { + t.Run("args="+args, func(t *testing.T) { + got := m.Dispatch(context.Background(), args, group) + if strings.Contains(got, deviceCode) { + t.Errorf("/login %q leaked the one-time code into a group: %q", args, got) + } + if strings.Contains(got, deviceURL) { + t.Errorf("/login %q leaked the verification link into a group: %q", args, got) + } + }) + } +} + +// TestGroupStatusStillReportsSomethingUseful: withholding the code must not turn +// into silence — a user in a group is told a sign-in is pending and where it went. +func TestGroupStatusStillReportsSomethingUseful(t *testing.T) { + m := NewManager(Config{ + Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: t.TempDir(), + }) + if got := m.Dispatch(context.Background(), "status", Subscriber{ID: "group"}); !strings.Contains(got, "NOT authorized") { + t.Errorf("group status = %q, want the state (no code is involved when none is pending)", got) + } + if got := m.Dispatch(context.Background(), "", Subscriber{ID: "group"}); !strings.Contains(got, "direct message") { + t.Errorf("group /login = %q, want the direct-message refusal", got) + } +} + +// TestGroupSubscriberNeverJoinsTheBroadcast: a non-private destination must not +// be subscribed either, or the prompt broadcast would hand it the code. +func TestGroupSubscriberNeverJoinsTheBroadcast(t *testing.T) { + bin := fakeCodex(t, printBanner+"sleep 30\n") + m := NewManager(Config{ + Backend: BackendCodex, + AuthMode: AuthSubscription, + RequireAuth: true, + Bin: bin, + Home: t.TempDir(), + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + t.Cleanup(func() { m.Cancel() }) + + group := newCollector() + dm := newCollector() + m.Dispatch(context.Background(), "", Subscriber{ID: "dm", Private: true, Notify: dm.notify}) + dm.next(t) // the prompt + m.Dispatch(context.Background(), "", Subscriber{ID: "group", Private: false, Notify: group.notify}) + + m.Cancel() + if got := dm.next(t); !strings.Contains(got, "cancelled") { + t.Fatalf("the private subscriber did not get the outcome: %q", got) + } + select { + case leaked := <-group.ch: + t.Errorf("a group destination received a login notice: %q", leaked) + case <-time.After(200 * time.Millisecond): + } +} + +// TestCancelWaitsForTheChild: Cancel must not return while the login goroutine is +// still unwinding. Shutdown relies on it to reap the polling child, and a /login +// issued straight after a cancel must start fresh rather than be told "already +// pending" alongside a code that is already dead. +func TestCancelWaitsForTheChild(t *testing.T) { + bin := fakeCodex(t, printBanner+"sleep 30\n") + m := NewManager(Config{ + Backend: BackendCodex, + AuthMode: AuthSubscription, + RequireAuth: true, + Bin: bin, + Home: t.TempDir(), + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + + c := newCollector() + m.Dispatch(context.Background(), "", c.sub("dm")) + if p := c.next(t); !strings.Contains(p, deviceCode) { + t.Fatalf("no prompt: %q", p) + } + + if !m.Cancel() { + t.Fatal("Cancel reported nothing pending") + } + // Immediately after Cancel the manager must already be idle. + if got := m.StatusText(); strings.Contains(got, "in progress") { + t.Errorf("StatusText = %q right after Cancel; the run was still marked pending", got) + } + if got := m.Dispatch(context.Background(), "", c.sub("dm")); strings.Contains(got, "already pending") { + t.Errorf("a fresh /login after cancel was refused with a dead code: %q", got) } + m.Cancel() } // TestEveryAskerLearnsTheOutcome: a second /login while one is pending re-shows diff --git a/docs/codex-integration-plan.md b/docs/codex-integration-plan.md index 885eada..3aaebc9 100644 --- a/docs/codex-integration-plan.md +++ b/docs/codex-integration-plan.md @@ -522,10 +522,14 @@ Delivered: marching into an unauthenticated CLI once per tick. The adapters keep an early check on the message path so an unauthorized deploy never pays for a voice transcription or an upload download. A blocked replay keeps its pending marker. -- `/login` starts a sign-in only in a direct message; `status` and `cancel` work - anywhere. `CODEX_REQUIRE_AUTH=false` still opts out of blocking entirely, and - startup logs `authorized` next to `credentials_present` so that opt-out cannot - read as "all good". +- Where a one-time code may be printed is decided by `codexauth` from the + destination, not by the adapters inspecting the arguments: `/login status` also + re-shows a pending code, so an argument-based guard misses it. A non-private + destination gets a redirect instead of the code, is refused when it tries to + START a sign-in, and is never subscribed to the prompt broadcast. +- `CODEX_REQUIRE_AUTH=false` still opts out of blocking entirely, and startup + logs `authorized` next to `credentials_present` so that opt-out cannot read as + "all good". Acceptance: From d41289ac66d769cb0a0b53a9d8e89fb40d9a1af3 Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 13:05:08 +0300 Subject: [PATCH 05/21] fix(codex): confirm the sign-in by its credential, and keep the code out of logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third review round on #66. The two items filed as non-blocking risks were the substantive ones. Success was announced on the CLI's exit code plus "a prompt was seen", without checking that a credential actually landed. A clean exit that persisted nothing told the user "Codex is authorized" and then refused their very next message — the run gate reads auth.json, not the exit status. Success is now confirmed by CredentialsPresent(); otherwise the reply names the directory that stayed empty. The captured CLI output went verbatim into error text, which is logged and may be shown in a chat — and that output contains the one-time code. The code is a live credential for as long as it lasts: anyone reading it out of a log can complete the sign-in with their own account. Errors now redact the code and the link while keeping the CLI's actual diagnosis, which is the part worth logging. Inline findings: - VK derived Private from the peer-id RANGE (!isGroupPeer), so every peer below 2e9 counted as private, community peers included. VK states the invariant directly — in a direct message the peer IS the sender — so the flag that decides who may take over the bot's account now uses peer_id == from_id. The two login fixtures that used an impossible from/peer combination are fixed too. - /help advertised /login on backends where it can only answer "not needed", contradicting the command menu, which already hid it. Both now key off the same auth.Applicable(), and a test asserts they agree. - The fake Codex CLI was duplicated in both adapter test packages and had drifted from the real captured banner — no ANSI, different wording — so an adapter test could stay green against output the production parser never sees. The banner and the script generator now live once, in core/codexauth/codexauthtest, used by all three packages. golangci-lint: 0 issues. Build, vet, full suite and -race across the tree green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/telegram/commands.go | 55 ++++++++++------ adapters/telegram/commands_test.go | 8 +-- adapters/vk/commands.go | 47 ++++++++++---- adapters/vk/receiver.go | 17 +++-- adapters/vk/receiver_test.go | 83 ++++++++++++++++++------- cmd/flock-telegram/codex_login_test.go | 53 ++++++++++------ cmd/flock-telegram/commands_test.go | 6 +- cmd/flock-telegram/main.go | 12 ++-- core/codexauth/codexauth.go | 10 +++ core/codexauth/codexauth_test.go | 43 ++++++++++++- core/codexauth/codexauthtest/fakecli.go | 61 ++++++++++++++++++ core/codexauth/login.go | 17 ++++- core/codexauth/login_test.go | 76 ++++++++++++---------- 13 files changed, 356 insertions(+), 132 deletions(-) create mode 100644 core/codexauth/codexauthtest/fakecli.go diff --git a/adapters/telegram/commands.go b/adapters/telegram/commands.go index 0915451..1dd63f4 100644 --- a/adapters/telegram/commands.go +++ b/adapters/telegram/commands.go @@ -82,23 +82,40 @@ func StripCommandMention(text, botUsername string) string { return token[:at] + rest } -// HelpText is the static usage message replied to an allowed user who sends -// /help. It lists the slash commands the adapter understands. It is an -// engineering artifact (professional English, no duck flavor) and never reaches -// the Claude Runner. -const HelpText = "Flock Telegram assistant — available commands:\n\n" + - "/help — show this message\n" + - "/new — start a fresh session (forget the current conversation)\n" + - "/stop — stop the run currently in progress\n" + - "/schedule — manage scheduled jobs (when enabled)\n" + - "/goal — arm a goal an independent evaluator re-checks after every run " + - "(/goal off to disarm)\n" + - "/login — sign in to Codex on a subscription (not needed on other backends; /login status, /login cancel)\n\n" + - "Send any other message to run it through the assistant." +// helpHeader/helpTail bracket the command list of the usage message replied to +// an allowed user who sends /help. It is an engineering artifact (professional +// English, no duck flavor) and never reaches the Runner. +const ( + helpHeader = "Flock Telegram assistant — available commands:\n\n" + + "/help — show this message\n" + + "/new — start a fresh session (forget the current conversation)\n" + + "/stop — stop the run currently in progress\n" + + "/schedule — manage scheduled jobs (when enabled)\n" + + "/goal — arm a goal an independent evaluator re-checks after every run " + + "(/goal off to disarm)\n" + helpTail = "\nSend any other message to run it through the assistant." +) + +// loginHelpLine documents /login. It is listed CONDITIONALLY, on the same +// applicability test the published command menu uses (reservedBotCommands), so +// the menu and /help cannot disagree: on Claude, an API-key backend or Codex +// billing there is no sign-in to complete and the command could only answer that +// it is not needed. +const loginHelpLine = "/login — sign in to Codex on a subscription (/login status, /login cancel)\n" + +// HelpText renders the usage message. withLogin adds the /login line. +func HelpText(withLogin bool) string { + if withLogin { + return helpHeader + loginHelpLine + helpTail + } + return helpHeader + helpTail +} -// WelcomeText is the static usage message replied to an allowed user who sends -// /start. It is a short greeting prepended to the command help so a brand-new -// user immediately sees what the bot does and how to use it. Like HelpText it is -// an engineering artifact (plain English, no duck flavor) and never reaches the -// Claude Runner — the duck greeting comes from the model on a real message. -const WelcomeText = "Hi! I'm the Flock assistant.\n\n" + HelpText +// WelcomeText is the reply to /start: a short greeting prepended to the command +// help, so a brand-new user immediately sees what the bot does and how to use +// it. Like HelpText it is an engineering artifact (plain English, no duck +// flavor) and never reaches the Runner — the duck greeting comes from the model +// on a real message. +func WelcomeText(withLogin bool) string { + return "Hi! I'm the Flock assistant.\n\n" + HelpText(withLogin) +} diff --git a/adapters/telegram/commands_test.go b/adapters/telegram/commands_test.go index fd07556..44ac497 100644 --- a/adapters/telegram/commands_test.go +++ b/adapters/telegram/commands_test.go @@ -96,12 +96,12 @@ func TestStripCommandMention(t *testing.T) { // it can never submit a run); the constant IS the entire payload the handler // sends. func TestHelpTextListsCommands(t *testing.T) { - if HelpText == "" { - t.Fatal("HelpText is empty") + if HelpText(true) == "" { + t.Fatal("HelpText(true) is empty") } for _, cmd := range []string{"/help", "/new", "/stop"} { - if !strings.Contains(HelpText, cmd) { - t.Fatalf("HelpText does not mention %q:\n%s", cmd, HelpText) + if !strings.Contains(HelpText(true), cmd) { + t.Fatalf("HelpText(true) does not mention %q:\n%s", cmd, HelpText(true)) } } } diff --git a/adapters/vk/commands.go b/adapters/vk/commands.go index a25d579..4da5f58 100644 --- a/adapters/vk/commands.go +++ b/adapters/vk/commands.go @@ -1,18 +1,39 @@ package vk -// HelpText is the static usage message for the VK adapter. It mirrors the -// Telegram adapter's HelpText and lists VK's command surface: VK has no native -// slash-command UI, so these are plain text commands the receiver intercepts. It -// is an engineering artifact (professional English, no duck flavor). -const HelpText = "Flock VK assistant — available commands:\n\n" + - "/help — show this message\n" + - "/new — start a fresh session (forget the current conversation)\n" + - "/stop — stop the run currently in progress\n" + - "/schedule — manage scheduled jobs (when enabled)\n" + - "/goal — arm a goal an independent evaluator re-checks after every run " + - "(/goal off to disarm)\n" + - "/login — sign in to Codex on a subscription (not needed on other backends; /login status, /login cancel)\n\n" + - "Send any other message to run it through the assistant." +// helpHeader/helpTail bracket the command list of the VK adapter's usage +// message. It mirrors the Telegram adapter's: VK has no native slash-command UI, +// so these are plain text commands the receiver intercepts. It is an engineering +// artifact (professional English, no duck flavor). +const ( + helpHeader = "Flock VK assistant — available commands:\n\n" + + "/help — show this message\n" + + "/new — start a fresh session (forget the current conversation)\n" + + "/stop — stop the run currently in progress\n" + + "/schedule — manage scheduled jobs (when enabled)\n" + + "/goal — arm a goal an independent evaluator re-checks after every run " + + "(/goal off to disarm)\n" + helpTail = "\nSend any other message to run it through the assistant." +) + +// loginHelpLine documents /login. It is listed CONDITIONALLY, on the same +// applicability test the Telegram command menu uses, so the two adapters and the +// menu cannot disagree: on Claude, an API-key backend or Codex billing there is +// no sign-in to complete and the command could only answer that it is not needed. +const loginHelpLine = "/login — sign in to Codex on a subscription (/login status, /login cancel)\n" + +// HelpText renders the usage message. withLogin adds the /login line. +func HelpText(withLogin bool) string { + if withLogin { + return helpHeader + loginHelpLine + helpTail + } + return helpHeader + helpTail +} + +// welcomeText is the reply to /start: a short greeting + the usage help, +// mirroring the Telegram adapter's WelcomeText. +func welcomeText(withLogin bool) string { + return "Hi! I'm the Flock assistant.\n\n" + HelpText(withLogin) +} // goalUsageText is the /goal usage reply, mirroring the Telegram adapter. const goalUsageText = "Usage:\n" + diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index 0b6dd1e..a917b96 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -26,11 +26,6 @@ const loginNotifyTimeout = 30 * time.Second // turned off (the default). It mirrors the Telegram adapter's notice. const scheduleDisabledText = "Scheduler is disabled. Set ENABLE_SCHEDULER=true to enable it." -// welcomeText is the static reply to /start: a short greeting + the usage help, -// mirroring the Telegram adapter's WelcomeText. It is an engineering artifact -// (plain English, no duck flavor) and never reaches the Claude Runner. -const welcomeText = "Hi! I'm the Flock assistant.\n\n" + HelpText - // newSessionText is the static reply to /new: confirms the session was reset so // the next message starts a brand-new conversation. const newSessionText = "Started a fresh session. Your next message begins a new conversation." @@ -406,9 +401,9 @@ func (r *Receiver) dispatchReserved(ctx context.Context, name string, msg messag peerID := msg.PeerID switch name { case "start": - r.notify(ctx, peerID, welcomeText) + r.notify(ctx, peerID, welcomeText(r.auth.Applicable())) case "help": - r.notify(ctx, peerID, HelpText) + r.notify(ctx, peerID, HelpText(r.auth.Applicable())) case "new": if err := r.svc.NewSession(chatIDStr(peerID)); err != nil { r.logger.Error("vk: reset session", "peer_id", peerID, "error", err) @@ -443,8 +438,12 @@ func (r *Receiver) dispatchReserved(ctx context.Context, name string, msg messag func (r *Receiver) dispatchLogin(ctx context.Context, msg messageObject) { peerID := msg.PeerID sub := codexauth.Subscriber{ - ID: chatIDStr(peerID), - Private: !isGroupPeer(peerID), + ID: chatIDStr(peerID), + // VK states the 1:1 invariant directly in the update: in a direct message the + // peer IS the sender. Deriving it from the peer-id range instead would call + // every non-conversation peer private, community peers included — too loose a + // test for a flag that decides who may take over the bot's account. + Private: peerID == msg.FromID, Notify: func(text string) { sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginNotifyTimeout) defer cancel() diff --git a/adapters/vk/receiver_test.go b/adapters/vk/receiver_test.go index 11eea74..a6af366 100644 --- a/adapters/vk/receiver_test.go +++ b/adapters/vk/receiver_test.go @@ -15,6 +15,7 @@ import ( "github.com/duckbugio/flock/core/agent" "github.com/duckbugio/flock/core/chat" "github.com/duckbugio/flock/core/codexauth" + "github.com/duckbugio/flock/core/codexauth/codexauthtest" "github.com/duckbugio/flock/core/goal" "github.com/duckbugio/flock/core/schedule" ) @@ -368,7 +369,7 @@ func TestReceiverStartTextCommand(t *testing.T) { if len(svc.handleCalls) != 0 { t.Errorf("/start should not start a run, got %d Handle calls", len(svc.handleCalls)) } - if len(notices.texts) != 1 || notices.texts[0] != welcomeText { + if len(notices.texts) != 1 || notices.texts[0] != welcomeText(false) { t.Errorf("notice texts = %v, want one welcome notice", notices.texts) } } @@ -414,7 +415,7 @@ func TestReceiverHelpTextCommand(t *testing.T) { if len(svc.handleCalls) != 0 { t.Errorf("/help should not start a run, got %d Handle calls", len(svc.handleCalls)) } - if len(notices.texts) != 1 || notices.texts[0] != HelpText { + if len(notices.texts) != 1 || notices.texts[0] != HelpText(false) { t.Errorf("notice texts = %v, want one HelpText notice", notices.texts) } } @@ -679,7 +680,7 @@ func TestReceiverLoginCommandStaysReachableWhileUnauthorized(t *testing.T) { notices := &fakeNotice{} r := unauthorizedCodexReceiver(t, svc, notices) - r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 200, Text: "/login status"})) + r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 42, Text: "/login status"})) if len(svc.handleCalls) != 0 { t.Errorf("/login should not start a run, got %d Handle calls", len(svc.handleCalls)) @@ -697,7 +698,7 @@ func TestReceiverLoginWithoutManagerIsInert(t *testing.T) { notices := &fakeNotice{} r := newTestReceiver(svc, notices, false, nil) - r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 200, Text: "/login"})) + r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 42, Text: "/login"})) if len(notices.texts) != 1 || notices.texts[0] != codexauth.NoLoginNeededText { t.Errorf("notices = %v, want the no-login-needed reply", notices.texts) } @@ -717,7 +718,7 @@ func pendingLoginManager(t *testing.T) *codexauth.Manager { AuthMode: codexauth.AuthSubscription, RequireAuth: true, Home: t.TempDir(), - Bin: fakeCodexLogin(t), + Bin: codexauthtest.WriteCLI(t, codexauthtest.Polling), Env: []string{"PATH=" + os.Getenv("PATH")}, }) t.Cleanup(func() { m.Cancel() }) @@ -734,21 +735,6 @@ func pendingLoginManager(t *testing.T) *codexauth.Manager { return m } -// fakeCodexLogin writes a stand-in Codex CLI that prints the real device-auth -// prompt and then blocks, like the real one polling for the browser step. -func fakeCodexLogin(t *testing.T) string { - t.Helper() - path := filepath.Join(t.TempDir(), "codex") - script := "#!/bin/sh\n" + - "printf '%s\\n' '1. Open this link' ' https://auth.openai.com/codex/device' \\\n" + - " '2. Enter this one-time code (expires in 15 minutes)' ' XER9-NWCA2'\n" + - "sleep 30\n" - if err := os.WriteFile(path, []byte(script), 0o755); err != nil { //nolint:gosec // test fixture must be executable - t.Fatalf("write fake codex: %v", err) - } - return path -} - // TestReceiverLoginRefusedInConversation: the reply goes to the PEER, so in a // community conversation the verification link and one-time code would be // readable by every participant — and whoever acts on the code first binds THEIR @@ -786,7 +772,7 @@ func TestReceiverLoginStatusInConversationHidesAPendingCode(t *testing.T) { if len(notices.texts) != 1 { t.Fatalf("notices = %v, want exactly one", notices.texts) } - if strings.Contains(notices.texts[0], "XER9-NWCA2") || strings.Contains(notices.texts[0], "http") { + if strings.Contains(notices.texts[0], codexauthtest.DeviceCode) || strings.Contains(notices.texts[0], "http") { t.Errorf("the pending code or link reached a conversation: %q", notices.texts[0]) } if !strings.Contains(notices.texts[0], "in progress") { @@ -804,7 +790,7 @@ func TestReceiverLoginStatusInDirectMessageShowsTheCode(t *testing.T) { r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 42, Text: "/login status"})) - if len(notices.texts) != 1 || !strings.Contains(notices.texts[0], "XER9-NWCA2") { + if len(notices.texts) != 1 || !strings.Contains(notices.texts[0], codexauthtest.DeviceCode) { t.Errorf("notices = %v, want the pending code re-shown in a direct message", notices.texts) } } @@ -824,3 +810,56 @@ func TestReceiverLoginRejectsDisallowedSender(t *testing.T) { t.Errorf("a disallowed sender got %v, want silence", notices.texts) } } + +// TestHelpListsLoginOnlyWhereItApplies mirrors the Telegram adapter: the /login +// line appears only where an interactive sign-in exists, so the two adapters (and +// Telegram's command menu) cannot disagree. +func TestHelpListsLoginOnlyWhereItApplies(t *testing.T) { + if !strings.Contains(HelpText(true), "/login") { + t.Error("help omits /login where the sign-in is real") + } + if strings.Contains(HelpText(false), "/login") { + t.Error("help advertises /login where there is no sign-in") + } + if strings.Contains(welcomeText(false), "/login") { + t.Error("welcome advertises /login where there is no sign-in") + } +} + +// TestPrivacyFollowsTheDirectMessageInvariant: VK states 1:1 directly — in a +// direct message the peer IS the sender. A range check on the peer id would call +// community peers private too, which is far too loose for the flag that decides +// who may see a code that takes over the bot's account. +func TestPrivacyFollowsTheDirectMessageInvariant(t *testing.T) { + auth := pendingLoginManager(t) + + tests := []struct { + name string + fromID int64 + peerID int64 + wantsCode bool + }{ + {"direct message", 42, 42, true}, + {"conversation", 42, 2000000001, false}, + {"community peer", 42, -1500, false}, + {"peer that is not the sender", 42, 200, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + notices := &fakeNotice{} + r := newTestReceiverWithAuth(&fakeService{}, notices, auth) + + r.dispatch(context.Background(), msgNewUpdate(t, messageObject{ + FromID: tt.fromID, PeerID: tt.peerID, Text: "/login status", + })) + + if len(notices.texts) != 1 { + t.Fatalf("notices = %v, want exactly one", notices.texts) + } + got := strings.Contains(notices.texts[0], codexauthtest.DeviceCode) + if got != tt.wantsCode { + t.Errorf("code shown = %v, want %v (reply: %q)", got, tt.wantsCode, notices.texts[0]) + } + }) + } +} diff --git a/cmd/flock-telegram/codex_login_test.go b/cmd/flock-telegram/codex_login_test.go index 569d31f..f8446cb 100644 --- a/cmd/flock-telegram/codex_login_test.go +++ b/cmd/flock-telegram/codex_login_test.go @@ -14,8 +14,10 @@ import ( "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" + "github.com/duckbugio/flock/adapters/telegram" "github.com/duckbugio/flock/core/chat" "github.com/duckbugio/flock/core/codexauth" + "github.com/duckbugio/flock/core/codexauth/codexauthtest" "github.com/duckbugio/flock/internal/config" ) @@ -214,7 +216,7 @@ func TestLoginStatusInGroupHidesAPendingCode(t *testing.T) { if len(got) != 1 { t.Fatalf("replies = %v, want exactly one", got) } - if strings.Contains(got[0], "XER9-NWCA2") || strings.Contains(got[0], "http") { + if strings.Contains(got[0], codexauthtest.DeviceCode) || strings.Contains(got[0], "http") { t.Errorf("the pending code or link reached a group: %q", got[0]) } if !strings.Contains(got[0], "in progress") { @@ -235,7 +237,7 @@ func TestLoginStatusInPrivateShowsThePendingCode(t *testing.T) { privateCommandUpdate(loginTestUserID, loginTestChatID, "/login status", len("/login"))) got := replies.seen() - if len(got) != 1 || !strings.Contains(got[0], "XER9-NWCA2") { + if len(got) != 1 || !strings.Contains(got[0], codexauthtest.DeviceCode) { t.Errorf("replies = %v, want the pending code re-shown in a direct message", got) } } @@ -249,7 +251,7 @@ func pendingLoginManager(t *testing.T) *codexauth.Manager { AuthMode: codexauth.AuthSubscription, RequireAuth: true, Home: t.TempDir(), - Bin: fakeCodexLogin(t), + Bin: codexauthtest.WriteCLI(t, codexauthtest.Polling), Env: []string{"PATH=" + os.Getenv("PATH")}, }) t.Cleanup(func() { m.Cancel() }) @@ -266,21 +268,6 @@ func pendingLoginManager(t *testing.T) *codexauth.Manager { return m } -// fakeCodexLogin writes a stand-in Codex CLI that prints the real device-auth -// prompt and then blocks, like the real one polling for the browser step. -func fakeCodexLogin(t *testing.T) string { - t.Helper() - path := filepath.Join(t.TempDir(), "codex") - script := "#!/bin/sh\n" + - "printf '%s\\n' '1. Open this link' ' https://auth.openai.com/codex/device' \\\n" + - " '2. Enter this one-time code (expires in 15 minutes)' ' XER9-NWCA2'\n" + - "sleep 30\n" - if err := os.WriteFile(path, []byte(script), 0o755); err != nil { //nolint:gosec // test fixture must be executable - t.Fatalf("write fake codex: %v", err) - } - return path -} - // commandBot builds a bot with the reserved handlers registered and every API // call answered locally by http. func commandBot(t *testing.T, cfg config.Config, auth *codexauth.Manager, client bot.HttpClient) *bot.Bot { @@ -399,3 +386,33 @@ func TestMenuKeepsLoginOnCodexSubscription(t *testing.T) { t.Error("/login is missing from the menu on a Codex subscription deployment") } } + +// TestHelpListsLoginOnlyWhereItApplies keeps /help and the published command menu +// on the SAME condition. The menu hides /login where no sign-in exists; a help +// text that still advertises it would send the user to a command that can only +// answer "not needed". +func TestHelpListsLoginOnlyWhereItApplies(t *testing.T) { + if !strings.Contains(telegram.HelpText(true), "/login") { + t.Error("help omits /login where the sign-in is real") + } + if strings.Contains(telegram.HelpText(false), "/login") { + t.Error("help advertises /login where there is no sign-in") + } + if !strings.Contains(telegram.WelcomeText(true), "/login") { + t.Error("welcome omits /login where the sign-in is real") + } + if strings.Contains(telegram.WelcomeText(false), "/login") { + t.Error("welcome advertises /login where there is no sign-in") + } + + // The menu and the help must agree, which is the whole point of one condition. + menuHasLogin := false + for _, c := range reservedBotCommands(codexAuthForMenu) { + if c.Command == loginCommand { + menuHasLogin = true + } + } + if menuHasLogin != strings.Contains(telegram.HelpText(codexAuthForMenu.Applicable()), "/login") { + t.Error("the command menu and /help disagree about /login") + } +} diff --git a/cmd/flock-telegram/commands_test.go b/cmd/flock-telegram/commands_test.go index de0c279..4c51483 100644 --- a/cmd/flock-telegram/commands_test.go +++ b/cmd/flock-telegram/commands_test.go @@ -129,10 +129,10 @@ func TestStartCommandRouted(t *testing.T) { // TestStartWelcomeText asserts the static /start reply is the welcome text: a // short greeting prepended to the usage help, plain English with no duck flavor. func TestStartWelcomeText(t *testing.T) { - if !strings.HasPrefix(telegram.WelcomeText, "Hi!") { - t.Fatalf("WelcomeText should open with a greeting, got %q", telegram.WelcomeText) + if !strings.HasPrefix(telegram.WelcomeText(false), "Hi!") { + t.Fatalf("WelcomeText should open with a greeting, got %q", telegram.WelcomeText(false)) } - if !strings.Contains(telegram.WelcomeText, telegram.HelpText) { + if !strings.Contains(telegram.WelcomeText(false), telegram.HelpText(false)) { t.Fatalf("WelcomeText should include the usage help (HelpText)") } } diff --git a/cmd/flock-telegram/main.go b/cmd/flock-telegram/main.go index f225a37..02cbe22 100644 --- a/cmd/flock-telegram/main.go +++ b/cmd/flock-telegram/main.go @@ -993,8 +993,8 @@ func reservedHandlers( cfg config.Config, svc *chat.Service, sched *schedule.Manager, auth *codexauth.Manager, ) map[string]bot.HandlerFunc { return map[string]bot.HandlerFunc{ - "start": startHandler(cfg), - "help": helpHandler(cfg), + "start": startHandler(cfg, auth), + "help": helpHandler(cfg, auth), "new": newHandler(cfg, svc), "stop": stopCommandHandler(cfg, svc), "schedule": scheduleHandler(cfg, sched), @@ -1072,25 +1072,25 @@ func commandSender(cfg config.Config, msg *models.Message) (int64, bool) { // allow-list, is not mention-gated, and never touches the dispatcher or session // store (no Claude run) — without it /start would fall through to the text // handler and be forwarded to the model as a prompt. -func startHandler(cfg config.Config) bot.HandlerFunc { +func startHandler(cfg config.Config, auth *codexauth.Manager) bot.HandlerFunc { return func(ctx context.Context, b *bot.Bot, update *models.Update) { chatID, ok := commandSender(cfg, update.Message) if !ok { return } - sendCommandReply(ctx, b, chatID, telegram.WelcomeText) + sendCommandReply(ctx, b, chatID, telegram.WelcomeText(auth.Applicable())) } } // helpHandler replies to /help from an allowed user with the static usage text. // It never touches the dispatcher or session store (no Claude run). -func helpHandler(cfg config.Config) bot.HandlerFunc { +func helpHandler(cfg config.Config, auth *codexauth.Manager) bot.HandlerFunc { return func(ctx context.Context, b *bot.Bot, update *models.Update) { chatID, ok := commandSender(cfg, update.Message) if !ok { return } - sendCommandReply(ctx, b, chatID, telegram.HelpText) + sendCommandReply(ctx, b, chatID, telegram.HelpText(auth.Applicable())) } } diff --git a/core/codexauth/codexauth.go b/core/codexauth/codexauth.go index 5fc804f..855738f 100644 --- a/core/codexauth/codexauth.go +++ b/core/codexauth/codexauth.go @@ -446,6 +446,16 @@ func (m *Manager) run(ctx context.Context, cancel context.CancelFunc, done chan m.mu.Unlock() if err == nil { + // A clean exit is necessary but not sufficient: what actually authorizes the + // deployment is the credential on disk, and that is what the run gate reads. + // Announcing success on the exit code alone would tell the user they are + // signed in and then refuse their very next message. + if !m.CredentialsPresent() { + log.Error("codex login exited cleanly but persisted no credential", "codex_home", m.cfg.Home) + m.broadcast("The Codex CLI finished the sign-in but saved no credential in " + m.cfg.Home + ".\n\n" + + "Check that the directory is writable, then send /login to try again.") + return + } log.Info("codex device login succeeded", "codex_home", m.cfg.Home) m.broadcast("Codex is authorized. Send your next message and the team gets to work.") return diff --git a/core/codexauth/codexauth_test.go b/core/codexauth/codexauth_test.go index bbfb2c5..982bf89 100644 --- a/core/codexauth/codexauth_test.go +++ b/core/codexauth/codexauth_test.go @@ -25,6 +25,11 @@ type collector struct { func newCollector() *collector { return &collector{ch: make(chan string, 8)} } +// persistAuth is the shell step a fake CLI uses to stand in for the browser +// half of the flow: a real successful login writes auth.json into CODEX_HOME, +// and that file — not the exit code — is what authorizes later runs. +const persistAuth = "sleep 0.3\necho '{}' > \"$CODEX_HOME/auth.json\"\n" + // dmSub is a private (1:1) destination — the only kind allowed to see a code. var dmSub = Subscriber{ID: "dm", Private: true} @@ -338,7 +343,7 @@ func TestStatusTextNeverLeaksSecrets(t *testing.T) { // cancelled) long before a human finishes signing in, so the login must NOT be // tied to it. func TestDispatchSurvivesADeadRequestContext(t *testing.T) { - bin := fakeCodex(t, printBanner+"sleep 0.3\nexit 0\n") + bin := fakeCodex(t, printBanner+persistAuth+"exit 0\n") m := NewManager(Config{ Backend: BackendCodex, AuthMode: AuthSubscription, @@ -531,7 +536,7 @@ func TestCancelWaitsForTheChild(t *testing.T) { // subscription, only the chat that happened to start the login would ever learn // whether it worked. func TestEveryAskerLearnsTheOutcome(t *testing.T) { - bin := fakeCodex(t, printBanner+"sleep 0.3\nexit 0\n") + bin := fakeCodex(t, printBanner+persistAuth+"exit 0\n") m := NewManager(Config{ Backend: BackendCodex, AuthMode: AuthSubscription, @@ -562,7 +567,7 @@ func TestEveryAskerLearnsTheOutcome(t *testing.T) { // TestTheSameChatAskingTwiceIsNotToldTwice: subscriptions are keyed by // destination, so a user tapping /login twice does not double every later notice. func TestTheSameChatAskingTwiceIsNotToldTwice(t *testing.T) { - bin := fakeCodex(t, printBanner+"sleep 0.3\nexit 0\n") + bin := fakeCodex(t, printBanner+persistAuth+"exit 0\n") m := NewManager(Config{ Backend: BackendCodex, AuthMode: AuthSubscription, @@ -586,3 +591,35 @@ func TestTheSameChatAskingTwiceIsNotToldTwice(t *testing.T) { case <-time.After(300 * time.Millisecond): } } + +// TestCleanExitWithoutACredentialIsNotSuccess: what authorizes the deployment is +// the credential on disk, which is what the run gate reads. Announcing success on +// the CLI's exit code alone would tell the user they are signed in and then +// refuse their very next message. +func TestCleanExitWithoutACredentialIsNotSuccess(t *testing.T) { + home := t.TempDir() + bin := fakeCodex(t, printBanner+"exit 0\n") // exits clean, writes nothing + m := NewManager(Config{ + Backend: BackendCodex, + AuthMode: AuthSubscription, + RequireAuth: true, + Bin: bin, + Home: home, + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + + c := newCollector() + m.Dispatch(context.Background(), "", c.sub("dm")) + c.next(t) // the prompt + + got := c.next(t) + if strings.Contains(strings.ToLower(got), "codex is authorized") { + t.Errorf("reported success with no persisted credential: %q", got) + } + if !strings.Contains(got, home) { + t.Errorf("final notice = %q, want it to name the directory that stayed empty", got) + } + if _, blocked := m.BlockedNotice(); !blocked { + t.Error("runs were unblocked by a login that persisted nothing") + } +} diff --git a/core/codexauth/codexauthtest/fakecli.go b/core/codexauth/codexauthtest/fakecli.go new file mode 100644 index 0000000..fe00413 --- /dev/null +++ b/core/codexauth/codexauthtest/fakecli.go @@ -0,0 +1,61 @@ +// Package codexauthtest provides the shared stand-in for the Codex CLI used by +// every test that drives a device login — core/codexauth's own, and both +// adapters'. +// +// It exists so the captured CLI output has ONE home. The banner below is real, +// copied verbatim from `codex login --device-auth`, ANSI escapes included: those +// escapes are half of what the parser has to survive, so a simplified local copy +// would let an adapter test pass against output the production parser would never +// see. With three copies, a change to Codex's output meant three edits and two +// chances to be quietly left behind. +package codexauthtest + +import ( + "os" + "path/filepath" + "testing" +) + +// The verification link and one-time code the banner carries. +const ( + DeviceURL = "https://auth.openai.com/codex/device" + DeviceCode = "XER9-NWCA2" +) + +// Banner is what `codex login --device-auth` really prints, captured from the +// CLI: an ANSI-colored preamble, the verification URL, and the one-time code on +// its own line. The escapes are REAL — surviving them is the point. +const Banner = "\n" + + "Welcome to Codex [v\x1b[90m0.146.0\x1b[0m]\n" + + "\x1b[90mOpenAI's command-line coding agent\x1b[0m\n" + + "\n" + + "Follow these steps to sign in with ChatGPT using device code authorization:\n" + + "\n" + + "1. Open this link in your browser and sign in to your account\n" + + " \x1b[94m" + DeviceURL + "\x1b[0m\n" + + "\n" + + "2. Enter this one-time code \x1b[90m(expires in 15 minutes)\x1b[0m\n" + + " \x1b[94m" + DeviceCode + "\x1b[0m\n" + +// PrintBanner is the shell snippet emitting Banner verbatim. A quoted heredoc +// keeps the apostrophes and escapes intact without shell interpretation. +const PrintBanner = "cat <<'CODEX_BANNER_EOF'" + Banner + "CODEX_BANNER_EOF\n" + +// Polling is a fake CLI that issues the prompt and then blocks, like the real one +// waiting on the browser step. +const Polling = PrintBanner + "sleep 30\n" + +// execPerm makes the written stand-in runnable, which is the whole point of it. +const execPerm os.FileMode = 0o755 + +// WriteCLI writes an executable stand-in for the Codex CLI whose body is the +// given shell script and returns its path. The script receives the same +// arguments the real CLI would. +func WriteCLI(t *testing.T, script string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "codex") + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script), execPerm); err != nil { + t.Fatalf("write fake codex: %v", err) + } + return path +} diff --git a/core/codexauth/login.go b/core/codexauth/login.go index 87bf770..561cfe4 100644 --- a/core/codexauth/login.go +++ b/core/codexauth/login.go @@ -201,12 +201,12 @@ func loginResult(ctx context.Context, waitErr error, output string, sawPrompt bo if waitErr != nil { var ee *exec.ExitError if errors.As(waitErr, &ee) { - return fmt.Errorf("codex login exited with code %d: %s", ee.ExitCode(), condense(output)) + return fmt.Errorf("codex login exited with code %d: %s", ee.ExitCode(), condense(redact(output))) } - return fmt.Errorf("codex login failed: %w: %s", waitErr, condense(output)) + return fmt.Errorf("codex login failed: %w: %s", waitErr, condense(redact(output))) } if !sawPrompt { - return fmt.Errorf("%w: %s", ErrNoPrompt, condense(output)) + return fmt.Errorf("%w: %s", ErrNoPrompt, condense(redact(output))) } return nil } @@ -226,6 +226,17 @@ func isUnsupportedFlag(output string) bool { strings.Contains(low, "invalid value") } +// redact removes the device-login secrets from captured CLI output before it is +// put in an error — which is logged, and may be shown in a chat. The one-time +// code is a live credential for as long as it lasts: anyone who reads it out of +// a log can complete the sign-in with their own account. The verification link +// goes too, since it is only useful together with the code and adds nothing to a +// diagnosis. +func redact(output string) string { + output = urlRe.ReplaceAllString(output, "") + return codeRe.ReplaceAllString(output, "") +} + // condense collapses captured CLI output into one line for an error message, // dropping blank lines so a mostly-decorative banner does not bury the cause. func condense(output string) string { diff --git a/core/codexauth/login_test.go b/core/codexauth/login_test.go index 967b967..2338b56 100644 --- a/core/codexauth/login_test.go +++ b/core/codexauth/login_test.go @@ -10,48 +10,26 @@ import ( "strings" "testing" "time" + + "github.com/duckbugio/flock/core/codexauth/codexauthtest" ) -// deviceAuthBanner is what `codex login --device-auth` really prints, captured -// from the CLI: an ANSI-colored banner, the verification URL, and the one-time -// code on its own line. The escapes are REAL — surviving them is the point. -const deviceAuthBanner = "\n" + - "Welcome to Codex [v\x1b[90m0.146.0\x1b[0m]\n" + - "\x1b[90mOpenAI's command-line coding agent\x1b[0m\n" + - "\n" + - "Follow these steps to sign in with ChatGPT using device code authorization:\n" + - "\n" + - "1. Open this link in your browser and sign in to your account\n" + - " \x1b[94mhttps://auth.openai.com/codex/device\x1b[0m\n" + - "\n" + - "2. Enter this one-time code \x1b[90m(expires in 15 minutes)\x1b[0m\n" + - " \x1b[94mXER9-NWCA2\x1b[0m\n" - -// The verification link and one-time code the banner carries, asserted across -// the parser and end-to-end cases. +// The fake CLI, its captured banner and the tokens it carries live in +// codexauthtest so core/codexauth, the VK adapter and the Telegram adapter all +// assert against the SAME real output. const ( - deviceURL = "https://auth.openai.com/codex/device" - deviceCode = "XER9-NWCA2" + deviceURL = codexauthtest.DeviceURL + deviceCode = codexauthtest.DeviceCode + printBanner = codexauthtest.PrintBanner ) -// printBanner is the shell snippet emitting deviceAuthBanner verbatim. A quoted -// heredoc keeps the apostrophes and escapes intact without shell interpretation. -const printBanner = "cat <<'CODEX_BANNER_EOF'" + deviceAuthBanner + "CODEX_BANNER_EOF\n" - -// fakeCodex writes an executable stand-in for the Codex CLI whose body is the -// given shell script, and returns its path. The script receives the same -// arguments the real CLI would. +// fakeCodex writes an executable stand-in for the Codex CLI running script. func fakeCodex(t *testing.T, script string) string { t.Helper() if runtime.GOOS == "windows" { t.Skip("the fake CLI is a POSIX shell script") } - path := filepath.Join(t.TempDir(), "codex") - body := "#!/bin/sh\n" + script - if err := os.WriteFile(path, []byte(body), 0o755); err != nil { //nolint:gosec // test fixture must be executable - t.Fatalf("write fake codex: %v", err) - } - return path + return codexauthtest.WriteCLI(t, script) } // testConfig points a Config at the fake CLI with a scratch CODEX_HOME and a @@ -419,3 +397,37 @@ func TestURLScore(t *testing.T) { }) } } + +// TestErrorsRedactTheOneTimeCode: a failure carries the CLI's own output, which +// is logged and may be shown in a chat — and that output contains a live +// credential. Anyone who reads the code out of a log can complete the sign-in +// with their own account, so it must never get that far. +func TestErrorsRedactTheOneTimeCode(t *testing.T) { + bin := fakeCodex(t, printBanner+"echo 'polling failed' >&2\nexit 4\n") + + err := Login(context.Background(), testConfig(t, bin), nil) + if err == nil { + t.Fatal("Login = nil, want an error") + } + if strings.Contains(err.Error(), deviceCode) { + t.Errorf("the one-time code leaked into an error: %q", err) + } + if strings.Contains(err.Error(), deviceURL) { + t.Errorf("the verification link leaked into an error: %q", err) + } + if !strings.Contains(err.Error(), "polling failed") { + t.Errorf("error %q lost the CLI's actual diagnosis", err) + } +} + +// TestRedactKeepsTheDiagnosis: redaction must not swallow the message that makes +// the failure debuggable. +func TestRedactKeepsTheDiagnosis(t *testing.T) { + got := redact("visit " + deviceURL + " and enter " + deviceCode + " — connection refused") + if strings.Contains(got, deviceCode) || strings.Contains(got, deviceURL) { + t.Errorf("redact left a secret in %q", got) + } + if !strings.Contains(got, "connection refused") { + t.Errorf("redact(%q) dropped the diagnosis", got) + } +} From 470b0c680bbd78d87d9ce9f35b717c436d1717a3 Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 13:19:12 +0300 Subject: [PATCH 06/21] fix(codex): gate InjectAuto, the sixth run source my comment claimed was covered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth review round on #66. MAJOR — the run gate missed InjectAuto. I enumerated the covered sources in three doc comments and named "workspace follow-up" among them; blockSubmit was never placed on that path. InjectAuto is precisely the unattended, recurring one: the follow-up sweeper, CI failure/green webhook events, and verify/goal fix-ups. It enqueues a pending marker before submit, so an unauthorized deployment leaked one marker per fire — the exact failure the previous round was supposed to close, still open on the path the comment asserted was closed. Gated now, and the enumerations say six, name each one, and point at the test that walks them. Also from the review: - The /login help line was two independent literals, one per adapter, under a comment claiming they "cannot disagree" — only the Applicability predicate was shared, never the words. It now lives once in core/chat beside the canonical command set, and a test asserts both adapters render that exact line. - The immediate acknowledgement was returned for the adapter to send while the login goroutine was already running and subscribed, so a CLI that printed its banner quickly could get the link and code out first — leaving the user reading "the code arrives in a moment" underneath the code. The ack now goes through the same ordered channel as every later notice, before the goroutine starts; Dispatch returns "" when it has been delivered, and returns the text unsent when the subscriber cannot receive it. golangci-lint: 0 issues. Build, vet, full suite and -race green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/telegram/commands.go | 11 +-- adapters/vk/commands.go | 10 +-- adapters/vk/receiver.go | 5 +- adapters/vk/receiver_test.go | 32 +++++++-- cmd/duck-vk/main.go | 9 +-- cmd/flock-telegram/codex_login_test.go | 32 +++++++-- cmd/flock-telegram/main.go | 14 ++-- core/chat/postrun.go | 6 ++ core/chat/reserved.go | 8 +++ core/chat/rungate.go | 15 ++-- core/chat/rungate_test.go | 2 + core/codexauth/codexauth.go | 24 ++++++- core/codexauth/codexauth_test.go | 99 ++++++++++++++++++++------ 13 files changed, 200 insertions(+), 67 deletions(-) diff --git a/adapters/telegram/commands.go b/adapters/telegram/commands.go index 1dd63f4..1a67edc 100644 --- a/adapters/telegram/commands.go +++ b/adapters/telegram/commands.go @@ -4,6 +4,8 @@ import ( "strings" "github.com/go-telegram/bot/models" + + "github.com/duckbugio/flock/core/chat" ) // CommandName returns the slash command addressed at the start of msg, @@ -96,17 +98,10 @@ const ( helpTail = "\nSend any other message to run it through the assistant." ) -// loginHelpLine documents /login. It is listed CONDITIONALLY, on the same -// applicability test the published command menu uses (reservedBotCommands), so -// the menu and /help cannot disagree: on Claude, an API-key backend or Codex -// billing there is no sign-in to complete and the command could only answer that -// it is not needed. -const loginHelpLine = "/login — sign in to Codex on a subscription (/login status, /login cancel)\n" - // HelpText renders the usage message. withLogin adds the /login line. func HelpText(withLogin bool) string { if withLogin { - return helpHeader + loginHelpLine + helpTail + return helpHeader + chat.LoginHelpLine + helpTail } return helpHeader + helpTail } diff --git a/adapters/vk/commands.go b/adapters/vk/commands.go index 4da5f58..88d9cac 100644 --- a/adapters/vk/commands.go +++ b/adapters/vk/commands.go @@ -1,5 +1,7 @@ package vk +import "github.com/duckbugio/flock/core/chat" + // helpHeader/helpTail bracket the command list of the VK adapter's usage // message. It mirrors the Telegram adapter's: VK has no native slash-command UI, // so these are plain text commands the receiver intercepts. It is an engineering @@ -15,16 +17,10 @@ const ( helpTail = "\nSend any other message to run it through the assistant." ) -// loginHelpLine documents /login. It is listed CONDITIONALLY, on the same -// applicability test the Telegram command menu uses, so the two adapters and the -// menu cannot disagree: on Claude, an API-key backend or Codex billing there is -// no sign-in to complete and the command could only answer that it is not needed. -const loginHelpLine = "/login — sign in to Codex on a subscription (/login status, /login cancel)\n" - // HelpText renders the usage message. withLogin adds the /login line. func HelpText(withLogin bool) string { if withLogin { - return helpHeader + loginHelpLine + helpTail + return helpHeader + chat.LoginHelpLine + helpTail } return helpHeader + helpTail } diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index a917b96..8879f8d 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -450,7 +450,10 @@ func (r *Receiver) dispatchLogin(ctx context.Context, msg messageObject) { r.notify(sendCtx, peerID, text) }, } - r.notify(ctx, peerID, r.auth.Dispatch(ctx, commandArgs(msg.Text), sub)) + // An empty reply means Dispatch already delivered it through sub, in order. + if reply := r.auth.Dispatch(ctx, commandArgs(msg.Text), sub); reply != "" { + r.notify(ctx, peerID, reply) + } } // dispatchGoal serves /goal: arm, show, or disarm the calling chat's goal via diff --git a/adapters/vk/receiver_test.go b/adapters/vk/receiver_test.go index a6af366..6d076a0 100644 --- a/adapters/vk/receiver_test.go +++ b/adapters/vk/receiver_test.go @@ -723,16 +723,24 @@ func pendingLoginManager(t *testing.T) *codexauth.Manager { }) t.Cleanup(func() { m.Cancel() }) - got := make(chan string, 4) + // The immediate acknowledgement now arrives through the SAME subscriber as the + // prompt (that ordering is deliberate), so drain until the code shows up rather + // than assuming the first notice is it. + issued := make(chan string, 8) m.Dispatch(context.Background(), "", codexauth.Subscriber{ - ID: "dm", Private: true, Notify: func(text string) { got <- text }, + ID: "dm", Private: true, Notify: func(text string) { issued <- text }, }) - select { - case <-got: - case <-time.After(10 * time.Second): - t.Fatal("the fake login never issued a prompt") + deadline := time.After(10 * time.Second) + for { + select { + case text := <-issued: + if strings.Contains(text, codexauthtest.DeviceCode) { + return m + } + case <-deadline: + t.Fatal("the fake login never issued a prompt") + } } - return m } // TestReceiverLoginRefusedInConversation: the reply goes to the PEER, so in a @@ -863,3 +871,13 @@ func TestPrivacyFollowsTheDirectMessageInvariant(t *testing.T) { }) } } + +// TestHelpLineComesFromTheCanonicalSet: both adapters render the /login help +// line, so it has one home beside the canonical command set. Two literals would +// drift silently — the adapters would still share the applicability predicate but +// not the words. +func TestHelpLineComesFromTheCanonicalSet(t *testing.T) { + if !strings.Contains(HelpText(true), chat.LoginHelpLine) { + t.Error("the VK help does not render the canonical /login line") + } +} diff --git a/cmd/duck-vk/main.go b/cmd/duck-vk/main.go index 7f36eed..b98349a 100644 --- a/cmd/duck-vk/main.go +++ b/cmd/duck-vk/main.go @@ -224,10 +224,11 @@ func run() int { Opts: opts, Timeout: cfg.ClaudeTimeout(), RetryAfter: vk.RetryAfter, - // The provider gate at the single point every run passes through — a user - // message, a cron fire, a workspace follow-up, a poller relay, the restart - // replay. The adapters block the message path early (before paid work); this - // is what stops the other four from marching into an unauthenticated CLI. + // The provider gate on every submit path — a user message and edits of one, a + // cron fire, the autonomy path (follow-ups, CI events, fix-ups), a poller + // relay, the restart replay. The adapters block the message path early + // (before paid work); this is what stops the rest from marching into an + // unauthenticated CLI and stranding a pending marker per fire. Auth: auth, Logger: logger, }) diff --git a/cmd/flock-telegram/codex_login_test.go b/cmd/flock-telegram/codex_login_test.go index f8446cb..52b2b0c 100644 --- a/cmd/flock-telegram/codex_login_test.go +++ b/cmd/flock-telegram/codex_login_test.go @@ -15,6 +15,7 @@ import ( "github.com/go-telegram/bot/models" "github.com/duckbugio/flock/adapters/telegram" + "github.com/duckbugio/flock/adapters/vk" "github.com/duckbugio/flock/core/chat" "github.com/duckbugio/flock/core/codexauth" "github.com/duckbugio/flock/core/codexauth/codexauthtest" @@ -256,16 +257,24 @@ func pendingLoginManager(t *testing.T) *codexauth.Manager { }) t.Cleanup(func() { m.Cancel() }) - issued := make(chan string, 4) + // The immediate acknowledgement now arrives through the SAME subscriber as the + // prompt (that ordering is deliberate), so drain until the code shows up rather + // than assuming the first notice is it. + issued := make(chan string, 8) m.Dispatch(context.Background(), "", codexauth.Subscriber{ ID: "dm", Private: true, Notify: func(text string) { issued <- text }, }) - select { - case <-issued: - case <-time.After(10 * time.Second): - t.Fatal("the fake login never issued a prompt") + deadline := time.After(10 * time.Second) + for { + select { + case text := <-issued: + if strings.Contains(text, codexauthtest.DeviceCode) { + return m + } + case <-deadline: + t.Fatal("the fake login never issued a prompt") + } } - return m } // commandBot builds a bot with the reserved handlers registered and every API @@ -416,3 +425,14 @@ func TestHelpListsLoginOnlyWhereItApplies(t *testing.T) { t.Error("the command menu and /help disagree about /login") } } + +// TestHelpLineComesFromTheCanonicalSet: both adapters render the same /login help +// line from core/chat, so a wording change cannot land in one and not the other. +func TestHelpLineComesFromTheCanonicalSet(t *testing.T) { + if !strings.Contains(telegram.HelpText(true), chat.LoginHelpLine) { + t.Error("the Telegram help does not render the canonical /login line") + } + if !strings.Contains(vk.HelpText(true), chat.LoginHelpLine) { + t.Error("the VK help does not render the canonical /login line") + } +} diff --git a/cmd/flock-telegram/main.go b/cmd/flock-telegram/main.go index 02cbe22..0526ecc 100644 --- a/cmd/flock-telegram/main.go +++ b/cmd/flock-telegram/main.go @@ -297,10 +297,11 @@ func run() int { Opts: opts, Timeout: cfg.ClaudeTimeout(), RetryAfter: telegram.RetryAfter, - // The provider gate at the single point every run passes through — a user - // message, a cron fire, a workspace follow-up, a poller relay, the restart - // replay. The adapters block the message path early (before paid work); this - // is what stops the other four from marching into an unauthenticated CLI. + // The provider gate on every submit path — a user message and edits of one, a + // cron fire, the autonomy path (follow-ups, CI events, fix-ups), a poller + // relay, the restart replay. The adapters block the message path early + // (before paid work); this is what stops the rest from marching into an + // unauthenticated CLI and stranding a pending marker per fire. Auth: auth, Logger: logger, }) @@ -1032,7 +1033,10 @@ func loginHandler(cfg config.Config, auth *codexauth.Manager) bot.HandlerFunc { sendCommandReply(sendCtx, b, chatID, text) }, } - sendCommandReply(ctx, b, chatID, auth.Dispatch(ctx, commandArgs(update.Message.Text), sub)) + // An empty reply means Dispatch already delivered it through sub, in order. + if reply := auth.Dispatch(ctx, commandArgs(update.Message.Text), sub); reply != "" { + sendCommandReply(ctx, b, chatID, reply) + } } } diff --git a/core/chat/postrun.go b/core/chat/postrun.go index d276645..aab56ae 100644 --- a/core/chat/postrun.go +++ b/core/chat/postrun.go @@ -82,6 +82,12 @@ type PostRunConfig struct { // resumes the job if the process dies mid-wait. ctx scopes only the budget // notice — the run itself executes under the Dispatcher's per-chat context. func (s *Service) InjectAuto(ctx context.Context, chatID ChatID, prompt string) { + // Before the budget check and before the marker: this path is recurring and + // unattended (the follow-up sweeper, CI events, verify/goal fix-ups), so an + // unauthorized provider would otherwise strand one marker per fire. + if s.blockSubmit(ctx, chatID) { + return + } if !s.autoAllowed(chatID) { s.log.Info("autonomy budget reached; dropping injected run", "chat_id", chatID) s.notifyBudgetOnce(ctx, chatID) diff --git a/core/chat/reserved.go b/core/chat/reserved.go index 6356139..8bafd5c 100644 --- a/core/chat/reserved.go +++ b/core/chat/reserved.go @@ -28,6 +28,14 @@ var ReservedCommands = []ReservedCommand{ {Name: "login", Description: "Sign in to Codex on a subscription (other backends need no login)"}, } +// LoginHelpLine is the /login entry for an adapter's /help text. It lives here, +// beside the canonical command set, because both adapters render it and a second +// copy would drift silently — the only thing they would still share is the +// applicability predicate, not the words. Adapters append it only when an +// interactive sign-in exists, the same condition that publishes /login in +// Telegram's command menu. +const LoginHelpLine = "/login — sign in to Codex on a subscription (/login status, /login cancel)\n" + // IsReservedCommand reports whether name is one of the bot's reserved commands. // The match is case-insensitive (name is normalized to lower case) and exact: // only the bare command word counts, so "new" matches but "news" does not, and a diff --git a/core/chat/rungate.go b/core/chat/rungate.go index 46eb19c..5713ada 100644 --- a/core/chat/rungate.go +++ b/core/chat/rungate.go @@ -7,12 +7,15 @@ import "context" // completed its interactive sign-in yet (core/codexauth.Manager satisfies it). // // The gate lives HERE, at the submit paths every run enters through, rather than -// only in the adapters' message paths. Runs reach the provider from five places — -// a user message, a poller-injected PR comment, a cron fire, a workspace -// follow-up and the restart replay of an interrupted run — and only the first of -// those goes through an adapter. Gating in the adapters alone would let the other -// four march into a CLI that cannot authenticate, once per schedule tick, with -// nothing user-visible to explain it. +// only in the adapters' message paths. Runs reach the provider from six places — +// a user message, an edit of one, a poller-injected PR comment, a cron fire, the +// autonomy path (workspace follow-ups, CI events, verify/goal fix-ups) and the +// restart replay of an interrupted run — and only the first two go through an +// adapter. Gating in the adapters alone would let the rest march into a CLI that +// cannot authenticate, once per tick, with nothing user-visible to explain it. +// +// Every one of those six calls blockSubmit; a seventh submit path must call it +// too. TestGateBlocksEveryRunSource enumerates them. type RunGate interface { // BlockedNotice returns the user-facing explanation and true when runs must // be blocked, or ("", false) when they may proceed. diff --git a/core/chat/rungate_test.go b/core/chat/rungate_test.go index 855fe19..a5c5a96 100644 --- a/core/chat/rungate_test.go +++ b/core/chat/rungate_test.go @@ -104,6 +104,7 @@ func TestGateBlocksEveryRunSource(t *testing.T) { {"poller relay", func(s *Service) { s.Inject(testChatID, "a reviewer commented") }}, {"cron fire", func(s *Service) { s.InjectScheduled(testChatID, "nightly", 7) }}, {"restart replay", func(s *Service) { s.ResumePending(testChatID, pending.Marker{ID: "p1", Prompt: "resume me"}) }}, + {"autonomy follow-up", func(s *Service) { s.InjectAuto(context.Background(), testChatID, "ci went red") }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -204,6 +205,7 @@ func TestBlockedSubmitLeavesNoPendingMarker(t *testing.T) { {"user message", func(s *Service) { s.Handle(context.Background(), testChatID, 7, "m1", "build it") }}, {"edited message", func(s *Service) { s.HandleEdit(context.Background(), testChatID, 7, "m1", "edited") }}, {"poller relay", func(s *Service) { s.Inject(testChatID, "a reviewer commented") }}, + {"autonomy follow-up", func(s *Service) { s.InjectAuto(context.Background(), testChatID, "ci went red") }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/core/codexauth/codexauth.go b/core/codexauth/codexauth.go index 855738f..0d3b446 100644 --- a/core/codexauth/codexauth.go +++ b/core/codexauth/codexauth.go @@ -316,7 +316,9 @@ func (s Subscriber) deliver(text string) { // normalizeArgs canonicalizes a /login argument for comparison. func normalizeArgs(args string) string { return strings.ToLower(strings.TrimSpace(args)) } -// Dispatch serves the /login command and returns the IMMEDIATE reply. +// Dispatch serves the /login command and returns the immediate reply for the +// caller to send — or "" when it has already been delivered through sub, which is +// how the reply is kept ahead of the notices that follow it. // // The device flow outlives the command: after the immediate reply, the pending // login keeps running in the background and reports the verification link, the @@ -394,8 +396,26 @@ func (m *Manager) start(base context.Context, force bool, sub Subscriber) string m.subscribeLocked(sub) m.mu.Unlock() + // Delivered through the SAME channel as every later notice, and BEFORE the run + // goroutine exists. Returning it for the adapter to send separately raced the + // broadcast: a CLI that printed its banner quickly could get the link and code + // out first, so the user read "the code arrives in a moment" underneath the + // code that had already arrived. + reply := m.ack(sub, "Starting the Codex sign-in — the link and one-time code arrive in a moment.") go m.run(ctx, cancel, done) - return "Starting the Codex sign-in — the link and one-time code arrive in a moment." + return reply +} + +// ack delivers an immediate reply through sub, so it cannot be overtaken by a +// notice the caller has not sent yet. It returns the text unsent only when the +// subscriber cannot receive it, leaving the caller to deliver it; an empty return +// means "already delivered, send nothing". +func (m *Manager) ack(sub Subscriber, text string) string { + if sub.Notify == nil { + return text + } + sub.deliver(text) + return "" } // subscribeLocked adds sub to the pending login's notice list, collapsing a diff --git a/core/codexauth/codexauth_test.go b/core/codexauth/codexauth_test.go index 982bf89..412cfb4 100644 --- a/core/codexauth/codexauth_test.go +++ b/core/codexauth/codexauth_test.go @@ -40,6 +40,25 @@ func (c *collector) sub(id string) Subscriber { return Subscriber{ID: id, Private: true, Notify: c.notify} } +// awaitCode drains notices until the one carrying the device code, returning it. +// The immediate acknowledgement now arrives through the SAME subscriber (that +// ordering is the point), so a test that only wants the prompt must skip past it. +func (c *collector) awaitCode(t *testing.T) string { + t.Helper() + deadline := time.After(10 * time.Second) + for { + select { + case text := <-c.ch: + if strings.Contains(text, deviceCode) { + return text + } + case <-deadline: + t.Fatal("no device code within 10s") + return "" + } + } +} + // next returns the next notice, failing the test if none arrives in time. func (c *collector) next(t *testing.T) string { t.Helper() @@ -206,10 +225,15 @@ func TestDispatchFullLoginPath(t *testing.T) { } c := newCollector() - if reply := m.Dispatch(context.Background(), "", c.sub("chat-1")); !strings.Contains(reply, "Starting") { - t.Errorf("immediate reply = %q, want an acknowledgement that the sign-in started", reply) + if reply := m.Dispatch(context.Background(), "", c.sub("chat-1")); reply != "" { + t.Errorf("immediate reply = %q, want it delivered through the subscriber instead", reply) } + // Order matters: the acknowledgement must not surface UNDER the code it + // promises, which is what sending it outside this channel used to risk. + if ack := c.next(t); !strings.Contains(ack, "Starting") { + t.Errorf("first notice = %q, want the acknowledgement first", ack) + } prompt := c.next(t) if !strings.Contains(prompt, "https://auth.openai.com/codex/device") { t.Errorf("prompt %q is missing the verification link", prompt) @@ -247,9 +271,7 @@ func TestDispatchReshowsPendingPrompt(t *testing.T) { c := newCollector() m.Dispatch(context.Background(), "", c.sub("chat-1")) - if prompt := c.next(t); !strings.Contains(prompt, "XER9-NWCA2") { - t.Fatalf("first prompt %q is missing the code", prompt) - } + c.awaitCode(t) again := m.Dispatch(context.Background(), "", c.sub("chat-1")) if !strings.Contains(again, "XER9-NWCA2") { @@ -287,7 +309,7 @@ func TestDispatchCancel(t *testing.T) { c := newCollector() m.Dispatch(context.Background(), "", c.sub("chat-1")) - c.next(t) // the prompt + c.awaitCode(t) if got := m.Dispatch(context.Background(), "cancel", c.sub("chat-1")); !strings.Contains(got, "Cancelled") { t.Errorf("cancel = %q, want a cancellation acknowledgement", got) @@ -358,9 +380,7 @@ func TestDispatchSurvivesADeadRequestContext(t *testing.T) { m.Dispatch(ctx, "", c.sub("chat-1")) cancel() // the update's context dies immediately, as it does in production - if prompt := c.next(t); !strings.Contains(prompt, "XER9-NWCA2") { - t.Errorf("prompt %q lost after the request context was cancelled", prompt) - } + c.awaitCode(t) if done := c.next(t); !strings.Contains(strings.ToLower(done), "authorized") { t.Errorf("final notice = %q, want the login to have completed anyway", done) } @@ -434,9 +454,7 @@ func TestNoCodeReachesANonPrivateDestination(t *testing.T) { // A sign-in is pending, started from a direct message. dm := newCollector() m.Dispatch(context.Background(), "", Subscriber{ID: "dm", Private: true, Notify: dm.notify}) - if p := dm.next(t); !strings.Contains(p, deviceCode) { - t.Fatalf("the private starter did not get the code: %q", p) - } + dm.awaitCode(t) group := Subscriber{ID: "group", Private: false, Notify: func(string) {}} for _, args := range []string{"", "status", "force", "again"} { @@ -483,7 +501,7 @@ func TestGroupSubscriberNeverJoinsTheBroadcast(t *testing.T) { group := newCollector() dm := newCollector() m.Dispatch(context.Background(), "", Subscriber{ID: "dm", Private: true, Notify: dm.notify}) - dm.next(t) // the prompt + dm.awaitCode(t) m.Dispatch(context.Background(), "", Subscriber{ID: "group", Private: false, Notify: group.notify}) m.Cancel() @@ -514,9 +532,7 @@ func TestCancelWaitsForTheChild(t *testing.T) { c := newCollector() m.Dispatch(context.Background(), "", c.sub("dm")) - if p := c.next(t); !strings.Contains(p, deviceCode) { - t.Fatalf("no prompt: %q", p) - } + c.awaitCode(t) if !m.Cancel() { t.Fatal("Cancel reported nothing pending") @@ -548,9 +564,7 @@ func TestEveryAskerLearnsTheOutcome(t *testing.T) { first, second := newCollector(), newCollector() m.Dispatch(context.Background(), "", first.sub("chat-1")) - if p := first.next(t); !strings.Contains(p, "XER9-NWCA2") { - t.Fatalf("first prompt %q is missing the code", p) - } + first.awaitCode(t) // A different chat asks while the login is pending. if again := m.Dispatch(context.Background(), "", second.sub("chat-2")); !strings.Contains(again, "XER9-NWCA2") { @@ -579,7 +593,7 @@ func TestTheSameChatAskingTwiceIsNotToldTwice(t *testing.T) { c := newCollector() m.Dispatch(context.Background(), "", c.sub("chat-1")) - c.next(t) // the prompt + c.awaitCode(t) m.Dispatch(context.Background(), "", c.sub("chat-1")) if got := c.next(t); !strings.Contains(strings.ToLower(got), "authorized") { @@ -610,7 +624,7 @@ func TestCleanExitWithoutACredentialIsNotSuccess(t *testing.T) { c := newCollector() m.Dispatch(context.Background(), "", c.sub("dm")) - c.next(t) // the prompt + c.awaitCode(t) got := c.next(t) if strings.Contains(strings.ToLower(got), "codex is authorized") { @@ -623,3 +637,46 @@ func TestCleanExitWithoutACredentialIsNotSuccess(t *testing.T) { t.Error("runs were unblocked by a login that persisted nothing") } } + +// TestAckPrecedesTheCode pins the message ORDER: the acknowledgement must not +// surface underneath the code it promises. +func TestAckPrecedesTheCode(t *testing.T) { + bin := fakeCodex(t, printBanner+"sleep 30\n") + m := NewManager(Config{ + Backend: BackendCodex, + AuthMode: AuthSubscription, + RequireAuth: true, + Bin: bin, + Home: t.TempDir(), + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + t.Cleanup(func() { m.Cancel() }) + + c := newCollector() + if reply := m.Dispatch(context.Background(), "", c.sub("dm")); reply != "" { + t.Fatalf("Dispatch returned %q, want it delivered through the subscriber", reply) + } + // Returning the acknowledgement for the caller to send separately raced the + // broadcast: a CLI that printed its banner quickly got the code out first, and + // the user read "the code arrives in a moment" underneath the code. + if got := c.next(t); !strings.Contains(got, "Starting") { + t.Errorf("first notice = %q, want the acknowledgement before the code", got) + } + if got := c.next(t); !strings.Contains(got, deviceCode) { + t.Errorf("second notice = %q, want the code", got) + } +} + +// TestDispatchStillReturnsAReplyWithoutASubscriber: a caller that cannot receive +// notices (no Notify) must still get the text to send itself. +func TestDispatchStillReturnsAReplyWithoutASubscriber(t *testing.T) { + m := NewManager(Config{ + Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: t.TempDir(), + Bin: filepath.Join(t.TempDir(), "no-such-codex"), + }) + t.Cleanup(func() { m.Cancel() }) + + if got := m.Dispatch(context.Background(), "", Subscriber{ID: "dm", Private: true}); got == "" { + t.Error("Dispatch swallowed the reply for a subscriber that cannot receive it") + } +} From 7721f107b8cba99346308233ad6f214e69b0bb53 Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 13:34:40 +0300 Subject: [PATCH 07/21] fix(codex): bound the unauthorized notice, and finish the de-duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth review round on #66. The notice sent when a run is refused went straight to the transport with the caller's context and no deadline — and three of the six callers run it inline in a loop: the PR poller dispatches from its own goroutine, and the restart replay walks every stored marker. A hung or 429-backing-off transport would have stopped those loops for good. It now goes through the package's existing notify helper, which detaches and bounds the send at 30s, exactly as the neighbouring budget-reached notice does. Also from the review: - The help BODY was two byte-identical copies, one per adapter, which is the very drift LoginHelpLine was moved to core/chat to prevent — the rule had been applied to one line out of six. chat.HelpBody now owns the command list and the closing line; an adapter supplies only its own title. - loginNotifyTimeout was declared once per binary for the same purpose; it is now codexauth.NotifyTimeout, beside the subscriber contract it serves. - statusText's authorized branch names CODEX_HOME, and /login status reaches it from anywhere — so a group chat was told a host path. The path is now private-only; the state is still reported everywhere. - env.j2 described /login without the direct-message requirement, unlike .env.example. An operator reads the rendered .env on the server, so that is exactly where the refusal would have been a surprise. - The pending-login test stand was duplicated across both adapter packages. The part that actually drifts — waiting past the acknowledgement for the code — is now codexauthtest.AwaitCode. The manager glue stays with each caller by necessity: a helper that built a codexauth.Manager would import codexauth, and codexauth's own tests import codexauthtest, which is an import cycle. golangci-lint: 0 issues. Build, vet, full suite and -race green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/telegram/commands.go | 33 ++++++------------- .../roles/claude_tg_bot/templates/env.j2 | 6 ++-- adapters/vk/commands.go | 29 +++++----------- adapters/vk/receiver.go | 7 +--- adapters/vk/receiver_test.go | 17 ++-------- cmd/flock-telegram/codex_login_test.go | 16 ++------- cmd/flock-telegram/main.go | 5 +-- core/chat/reserved.go | 28 ++++++++++++++++ core/chat/reserved_test.go | 26 +++++++++++++++ core/chat/rungate.go | 13 ++++---- core/codexauth/codexauth.go | 10 +++++- core/codexauth/codexauth_test.go | 22 +++++++++++++ core/codexauth/codexauthtest/fakecli.go | 31 +++++++++++++++++ 13 files changed, 151 insertions(+), 92 deletions(-) diff --git a/adapters/telegram/commands.go b/adapters/telegram/commands.go index 1a67edc..b4dc385 100644 --- a/adapters/telegram/commands.go +++ b/adapters/telegram/commands.go @@ -84,33 +84,20 @@ func StripCommandMention(text, botUsername string) string { return token[:at] + rest } -// helpHeader/helpTail bracket the command list of the usage message replied to -// an allowed user who sends /help. It is an engineering artifact (professional -// English, no duck flavor) and never reaches the Runner. -const ( - helpHeader = "Flock Telegram assistant — available commands:\n\n" + - "/help — show this message\n" + - "/new — start a fresh session (forget the current conversation)\n" + - "/stop — stop the run currently in progress\n" + - "/schedule — manage scheduled jobs (when enabled)\n" + - "/goal — arm a goal an independent evaluator re-checks after every run " + - "(/goal off to disarm)\n" - helpTail = "\nSend any other message to run it through the assistant." -) +// helpTitle names the transport; everything below it is shared with the VK +// adapter (chat.HelpBody), so the two cannot drift apart. +const helpTitle = "Flock Telegram assistant — available commands:\n\n" -// HelpText renders the usage message. withLogin adds the /login line. -func HelpText(withLogin bool) string { - if withLogin { - return helpHeader + chat.LoginHelpLine + helpTail - } - return helpHeader + helpTail -} +// HelpText renders the usage message replied to an allowed user who sends /help. +// withLogin adds the /login line, on the same condition that publishes /login in +// the command menu. It is an engineering artifact (professional English, no duck +// flavor) and never reaches the Runner. +func HelpText(withLogin bool) string { return helpTitle + chat.HelpBody(withLogin) } // WelcomeText is the reply to /start: a short greeting prepended to the command // help, so a brand-new user immediately sees what the bot does and how to use -// it. Like HelpText it is an engineering artifact (plain English, no duck -// flavor) and never reaches the Runner — the duck greeting comes from the model -// on a real message. +// it. Like HelpText it never reaches the Runner — the duck greeting comes from +// the model on a real message. func WelcomeText(withLogin bool) string { return "Hi! I'm the Flock assistant.\n\n" + HelpText(withLogin) } diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 b/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 index 5c2c237..c560a59 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 +++ b/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 @@ -30,8 +30,10 @@ CLAUDE_MAX_COST_PER_REQUEST={{ claude_max_cost_per_request }} # === CODEX AUTH === # subscription: use persisted {{ codex_home }}/auth.json or CODEX_ACCESS_TOKEN. # With neither, the bot starts unauthorized and an allow-listed user completes -# the one-time sign-in from the chat with /login (device code: the bot posts a -# link + a one-time code). No shell on the host is needed. +# the one-time sign-in from the chat with /login IN A DIRECT MESSAGE — the reply +# carries a one-time code that authorizes an account for the whole bot, so +# starting a sign-in is refused in group chats. The bot posts a link + a +# one-time code. No shell on the host is needed. # billing: requires CODEX_API_KEY and CODEX_BILLING_ACK=true. CODEX_BIN={{ codex_bin }} CODEX_MODEL={{ codex_model }} diff --git a/adapters/vk/commands.go b/adapters/vk/commands.go index 88d9cac..a8b9f3d 100644 --- a/adapters/vk/commands.go +++ b/adapters/vk/commands.go @@ -2,28 +2,15 @@ package vk import "github.com/duckbugio/flock/core/chat" -// helpHeader/helpTail bracket the command list of the VK adapter's usage -// message. It mirrors the Telegram adapter's: VK has no native slash-command UI, -// so these are plain text commands the receiver intercepts. It is an engineering -// artifact (professional English, no duck flavor). -const ( - helpHeader = "Flock VK assistant — available commands:\n\n" + - "/help — show this message\n" + - "/new — start a fresh session (forget the current conversation)\n" + - "/stop — stop the run currently in progress\n" + - "/schedule — manage scheduled jobs (when enabled)\n" + - "/goal — arm a goal an independent evaluator re-checks after every run " + - "(/goal off to disarm)\n" - helpTail = "\nSend any other message to run it through the assistant." -) +// helpTitle names the transport; everything below it is shared with the Telegram +// adapter (chat.HelpBody), so the two cannot drift apart. VK has no native +// slash-command UI, so these are plain text commands the receiver intercepts. +const helpTitle = "Flock VK assistant — available commands:\n\n" -// HelpText renders the usage message. withLogin adds the /login line. -func HelpText(withLogin bool) string { - if withLogin { - return helpHeader + chat.LoginHelpLine + helpTail - } - return helpHeader + helpTail -} +// HelpText renders the usage message. withLogin adds the /login line, on the same +// condition that publishes /login in Telegram's command menu. It is an +// engineering artifact (professional English, no duck flavor). +func HelpText(withLogin bool) string { return helpTitle + chat.HelpBody(withLogin) } // welcomeText is the reply to /start: a short greeting + the usage help, // mirroring the Telegram adapter's WelcomeText. diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index 8879f8d..aefe866 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -17,11 +17,6 @@ import ( "github.com/duckbugio/flock/core/schedule" ) -// loginNotifyTimeout bounds one background Codex-login notice delivery. The -// device flow reports minutes after the /login update's own context is gone, so -// each notice is sent on a fresh, bounded context of its own. -const loginNotifyTimeout = 30 * time.Second - // scheduleDisabledText is the reply when /schedule is used but the scheduler is // turned off (the default). It mirrors the Telegram adapter's notice. const scheduleDisabledText = "Scheduler is disabled. Set ENABLE_SCHEDULER=true to enable it." @@ -445,7 +440,7 @@ func (r *Receiver) dispatchLogin(ctx context.Context, msg messageObject) { // test for a flag that decides who may take over the bot's account. Private: peerID == msg.FromID, Notify: func(text string) { - sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginNotifyTimeout) + sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexauth.NotifyTimeout) defer cancel() r.notify(sendCtx, peerID, text) }, diff --git a/adapters/vk/receiver_test.go b/adapters/vk/receiver_test.go index 6d076a0..2e3338e 100644 --- a/adapters/vk/receiver_test.go +++ b/adapters/vk/receiver_test.go @@ -10,7 +10,6 @@ import ( "strings" "sync" "testing" - "time" "github.com/duckbugio/flock/core/agent" "github.com/duckbugio/flock/core/chat" @@ -723,24 +722,12 @@ func pendingLoginManager(t *testing.T) *codexauth.Manager { }) t.Cleanup(func() { m.Cancel() }) - // The immediate acknowledgement now arrives through the SAME subscriber as the - // prompt (that ordering is deliberate), so drain until the code shows up rather - // than assuming the first notice is it. issued := make(chan string, 8) m.Dispatch(context.Background(), "", codexauth.Subscriber{ ID: "dm", Private: true, Notify: func(text string) { issued <- text }, }) - deadline := time.After(10 * time.Second) - for { - select { - case text := <-issued: - if strings.Contains(text, codexauthtest.DeviceCode) { - return m - } - case <-deadline: - t.Fatal("the fake login never issued a prompt") - } - } + codexauthtest.AwaitCode(t, issued) + return m } // TestReceiverLoginRefusedInConversation: the reply goes to the PEER, so in a diff --git a/cmd/flock-telegram/codex_login_test.go b/cmd/flock-telegram/codex_login_test.go index 52b2b0c..8dc32b4 100644 --- a/cmd/flock-telegram/codex_login_test.go +++ b/cmd/flock-telegram/codex_login_test.go @@ -257,24 +257,12 @@ func pendingLoginManager(t *testing.T) *codexauth.Manager { }) t.Cleanup(func() { m.Cancel() }) - // The immediate acknowledgement now arrives through the SAME subscriber as the - // prompt (that ordering is deliberate), so drain until the code shows up rather - // than assuming the first notice is it. issued := make(chan string, 8) m.Dispatch(context.Background(), "", codexauth.Subscriber{ ID: "dm", Private: true, Notify: func(text string) { issued <- text }, }) - deadline := time.After(10 * time.Second) - for { - select { - case text := <-issued: - if strings.Contains(text, codexauthtest.DeviceCode) { - return m - } - case <-deadline: - t.Fatal("the fake login never issued a prompt") - } - } + codexauthtest.AwaitCode(t, issued) + return m } // commandBot builds a bot with the reserved handlers registered and every API diff --git a/cmd/flock-telegram/main.go b/cmd/flock-telegram/main.go index 0526ecc..1884580 100644 --- a/cmd/flock-telegram/main.go +++ b/cmd/flock-telegram/main.go @@ -1028,7 +1028,7 @@ func loginHandler(cfg config.Config, auth *codexauth.Manager) bot.HandlerFunc { ID: chatIDStr(chatID), Private: update.Message.Chat.Type == models.ChatTypePrivate, Notify: func(text string) { - sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginNotifyTimeout) + sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexauth.NotifyTimeout) defer cancel() sendCommandReply(sendCtx, b, chatID, text) }, @@ -1040,9 +1040,6 @@ func loginHandler(cfg config.Config, auth *codexauth.Manager) bot.HandlerFunc { } } -// loginNotifyTimeout bounds one background login notice delivery. -const loginNotifyTimeout = 30 * time.Second - // loginCommand is the reserved command name the sign-in flow is published under. const loginCommand = "login" diff --git a/core/chat/reserved.go b/core/chat/reserved.go index 8bafd5c..90580c9 100644 --- a/core/chat/reserved.go +++ b/core/chat/reserved.go @@ -28,6 +28,34 @@ var ReservedCommands = []ReservedCommand{ {Name: "login", Description: "Sign in to Codex on a subscription (other backends need no login)"}, } +// HelpBody is the shared body of both adapters' /help: the command list and the +// closing line. Only the first line — which names the transport — differs, so +// that is all an adapter supplies. It lives here, beside the canonical set it has +// to stay in step with, for the same reason as LoginHelpLine: two byte-identical +// copies share a predicate, never their words, and drift in silence. +// +// withLogin adds the /login entry, on the same condition that publishes /login in +// Telegram's command menu. +func HelpBody(withLogin bool) string { + body := helpCommands + if withLogin { + body += LoginHelpLine + } + return body + helpTail +} + +// helpCommands lists the reserved commands with the fuller wording a chat reply +// affords, next to the terse Description the command menu uses. +const helpCommands = "/help — show this message\n" + + "/new — start a fresh session (forget the current conversation)\n" + + "/stop — stop the run currently in progress\n" + + "/schedule — manage scheduled jobs (when enabled)\n" + + "/goal — arm a goal an independent evaluator re-checks after every run " + + "(/goal off to disarm)\n" + +// helpTail closes the usage message. +const helpTail = "\nSend any other message to run it through the assistant." + // LoginHelpLine is the /login entry for an adapter's /help text. It lives here, // beside the canonical command set, because both adapters render it and a second // copy would drift silently — the only thing they would still share is the diff --git a/core/chat/reserved_test.go b/core/chat/reserved_test.go index 8702ea6..2d11681 100644 --- a/core/chat/reserved_test.go +++ b/core/chat/reserved_test.go @@ -1,6 +1,7 @@ package chat_test import ( + "strings" "testing" "github.com/duckbugio/flock/core/chat" @@ -89,3 +90,28 @@ func lower(s string) string { } return string(out) } + +// TestHelpBodyIsTheSharedSource: both adapters render this body, so the command +// list has one home beside the canonical set. Two byte-identical copies would +// share the applicability predicate but never the words, and would drift in +// silence — the reason LoginHelpLine moved here in the first place. +func TestHelpBodyIsTheSharedSource(t *testing.T) { + withLogin := chat.HelpBody(true) + without := chat.HelpBody(false) + + if !strings.Contains(withLogin, chat.LoginHelpLine) { + t.Error("HelpBody(true) omits the /login line") + } + if strings.Contains(without, "/login") { + t.Error("HelpBody(false) advertises /login where there is no sign-in") + } + // Every reserved command except /login is unconditional, so each must appear. + for _, c := range chat.ReservedCommands { + if c.Name == "login" || c.Name == "start" { + continue // /login is conditional; /start is not listed (it IS the greeting) + } + if !strings.Contains(without, "/"+c.Name) { + t.Errorf("HelpBody omits the reserved command /%s", c.Name) + } + } +} diff --git a/core/chat/rungate.go b/core/chat/rungate.go index 5713ada..bc11acf 100644 --- a/core/chat/rungate.go +++ b/core/chat/rungate.go @@ -61,12 +61,13 @@ func (s *Service) blockSubmit(ctx context.Context, chatID ChatID) bool { s.mu.Unlock() if first && notice != "" { - // Sent synchronously on the caller's own goroutine, so a request context is - // still alive where there is one; the background sources pass context - // Background because they have none. - if _, err := s.chat.Send(ctx, chatID, notice, "", true); err != nil { - s.log.Error("send unauthorized notice", "chat_id", chatID, "error", err) - } + // Via notify, which bounds the send: this runs on the CALLER's goroutine, and + // three of the six callers are loops that must not be stalled — the PR poller + // dispatches inline, and the restart replay walks every stored marker. A + // transport that hangs or sits in a 429 back-off would otherwise stop them + // for good. The refusal itself is already logged at Warn above, so notify's + // Debug-level delivery failure is enough. + s.notify(ctx, chatID, notice) } return true } diff --git a/core/codexauth/codexauth.go b/core/codexauth/codexauth.go index 0d3b446..921e907 100644 --- a/core/codexauth/codexauth.go +++ b/core/codexauth/codexauth.go @@ -239,8 +239,11 @@ func (m *Manager) statusText(private bool) string { return "Codex device login is starting; the link and code arrive in a moment." case m.cfg.HasAccessToken: return "Codex is authorized with a configured access token. No browser login needed." - case m.CredentialsPresent(): + case m.CredentialsPresent() && private: return "Codex is authorized (persisted login in " + m.cfg.Home + ")." + case m.CredentialsPresent(): + // The host path is deployment detail; a group chat has no business with it. + return "Codex is authorized." case !m.cfg.RequireAuth: return "No persisted Codex login found, but CODEX_REQUIRE_AUTH=false, so runs are not blocked. " + "Send /login if Codex reports it is unauthenticated." @@ -284,6 +287,11 @@ const LoginUsage = "Usage:\n" + "/login cancel — abort a pending sign-in\n" + "/login force — sign in again even if already authorized" +// NotifyTimeout bounds ONE background notice delivery. Both adapters use it for +// their Subscriber.Notify: a pending login reports minutes after the update that +// started it is gone, so each notice needs a fresh, bounded context of its own. +const NotifyTimeout = 30 * time.Second + // Subscriber is where a /login reply and a pending login's background notices // are delivered. // diff --git a/core/codexauth/codexauth_test.go b/core/codexauth/codexauth_test.go index 412cfb4..3e2751e 100644 --- a/core/codexauth/codexauth_test.go +++ b/core/codexauth/codexauth_test.go @@ -680,3 +680,25 @@ func TestDispatchStillReturnsAReplyWithoutASubscriber(t *testing.T) { t.Error("Dispatch swallowed the reply for a subscriber that cannot receive it") } } + +// TestGroupStatusHidesTheHostPath: the authorized branch names CODEX_HOME, which +// is deployment detail a group chat has no business with. /login status reaches +// that branch from anywhere, so the path is private-only too. +func TestGroupStatusHidesTheHostPath(t *testing.T) { + home := t.TempDir() + writeAuthFile(t, home) + m := NewManager(Config{ + Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: home, + }) + + group := m.Dispatch(context.Background(), "status", Subscriber{ID: "group"}) + if strings.Contains(group, home) { + t.Errorf("group status leaked the host path: %q", group) + } + if !strings.Contains(group, "authorized") { + t.Errorf("group status = %q, want it to still report the state", group) + } + if dm := m.Dispatch(context.Background(), "status", dmSub); !strings.Contains(dm, home) { + t.Errorf("direct-message status = %q, want the path an operator needs", dm) + } +} diff --git a/core/codexauth/codexauthtest/fakecli.go b/core/codexauth/codexauthtest/fakecli.go index fe00413..13c31d3 100644 --- a/core/codexauth/codexauthtest/fakecli.go +++ b/core/codexauth/codexauthtest/fakecli.go @@ -13,7 +13,9 @@ package codexauthtest import ( "os" "path/filepath" + "strings" "testing" + "time" ) // The verification link and one-time code the banner carries. @@ -59,3 +61,32 @@ func WriteCLI(t *testing.T, script string) string { } return path } + +// promptWait bounds how long AwaitCode waits for the fake CLI's prompt. +const promptWait = 10 * time.Second + +// AwaitCode drains delivered notices until the one carrying DeviceCode and +// returns it. A pending login delivers its acknowledgement through the SAME +// channel as the prompt, and deliberately ahead of it, so a caller that wants the +// code has to skip past what precedes it. +// +// This package stops at the fixtures on purpose: a helper that built a +// codexauth.Manager would have to import codexauth, and codexauth's own tests +// import this package — that is an import cycle. What actually drifts (the +// captured banner and its tokens) is shared; the few lines of manager glue stay +// with each caller. +func AwaitCode(t *testing.T, notices <-chan string) string { + t.Helper() + deadline := time.After(promptWait) + for { + select { + case text := <-notices: + if strings.Contains(text, DeviceCode) { + return text + } + case <-deadline: + t.Fatal("the fake login never issued a prompt") + return "" + } + } +} From 996114722bd758fa37e99e88fddd00ed3f72dec9 Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 13:51:50 +0300 Subject: [PATCH 08/21] fix(codex): gate /login cancel too, and make the docs match the flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth review round on #66. /login cancel was the one argument that ignored the destination. It is also the only state-MUTATING one, so an allow-listed member of a conversation could abort a sign-in running in someone else's direct message — and the confirmation would itself have revealed that one was under way. start refused correctly and status redacted correctly; cancel was the hole. Control of a login now stays on the 1:1 channel that owns it, which makes the whole command describable by one rule. Also from the review: - .env.example and env.j2 promised "runs are blocked until then" unconditionally, but blocking is CODEX_REQUIRE_AUTH's decision, and the operator reading the rendered .env is exactly the one who may have turned it off. Both now tie the claim to the flag and say what happens without it. - HelpText/WelcomeText took a bare bool, which reads as HelpText(true) at half the call sites. Replaced with chat.LoginVisibility and its WithLogin / WithoutLogin constants, plus LoginVisibilityFor to derive it. - blockSubmit cleared the "already told them" memory only for the chat that happened to submit, while the comment promised a later lapse would be announced afresh. A chat that stayed quiet through a recovery window would have been silently skipped. Authorization is process-wide, so recovery now clears every chat — making the comment true rather than softening it. - The pending-login test stand is now logintest.PendingLogin, used by both adapters. It is a second package because it imports codexauth, while codexauthtest must stay import-free of it (codexauth's own tests use the fixtures) — the reason is recorded in the package doc. - The Telegram test pulled in adapters/vk for one assertion the vk package already makes; dropped. golangci-lint: 0 issues. Build, vet, full suite and -race green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/telegram/.env.example | 4 +- adapters/telegram/commands.go | 8 ++-- adapters/telegram/commands_test.go | 10 ++-- .../roles/claude_tg_bot/templates/env.j2 | 5 +- adapters/vk/commands.go | 6 +-- adapters/vk/receiver.go | 4 +- adapters/vk/receiver_test.go | 42 ++++------------- cmd/flock-telegram/codex_login_test.go | 43 ++++------------- cmd/flock-telegram/commands_test.go | 6 +-- cmd/flock-telegram/main.go | 4 +- core/chat/reserved.go | 26 ++++++++++- core/chat/reserved_test.go | 4 +- core/chat/rungate.go | 6 ++- core/chat/rungate_test.go | 23 ++++++++++ core/codexauth/codexauth.go | 7 +++ core/codexauth/codexauth_test.go | 33 +++++++++++++ core/codexauth/logintest/logintest.go | 46 +++++++++++++++++++ 17 files changed, 186 insertions(+), 91 deletions(-) create mode 100644 core/codexauth/logintest/logintest.go diff --git a/adapters/telegram/.env.example b/adapters/telegram/.env.example index b04c42b..beb0859 100644 --- a/adapters/telegram/.env.example +++ b/adapters/telegram/.env.example @@ -27,7 +27,9 @@ AI_BACKEND=claude # claude | codex | openai-compatible # verification link and a one-time code (this is `codex login --device-auth`; # plain `codex login` would open a loopback server on localhost:1455 that nothing # outside the container can reach, which is why it appears to hang). Open the -# link, enter the code, and the bot confirms — runs are blocked until then. +# link, enter the code, and the bot confirms. With CODEX_REQUIRE_AUTH=true (the +# default below) runs are blocked until then; with it false the bot runs +# immediately and an unfinished sign-in surfaces as a Codex CLI failure instead. CODEX_AUTH_MODE=subscription # subscription | billing CODEX_BIN=codex CODEX_HOME=/home/claude/.codex diff --git a/adapters/telegram/commands.go b/adapters/telegram/commands.go index b4dc385..f3c9b21 100644 --- a/adapters/telegram/commands.go +++ b/adapters/telegram/commands.go @@ -89,15 +89,15 @@ func StripCommandMention(text, botUsername string) string { const helpTitle = "Flock Telegram assistant — available commands:\n\n" // HelpText renders the usage message replied to an allowed user who sends /help. -// withLogin adds the /login line, on the same condition that publishes /login in +// login adds the /login line, on the same condition that publishes /login in // the command menu. It is an engineering artifact (professional English, no duck // flavor) and never reaches the Runner. -func HelpText(withLogin bool) string { return helpTitle + chat.HelpBody(withLogin) } +func HelpText(login chat.LoginVisibility) string { return helpTitle + chat.HelpBody(login) } // WelcomeText is the reply to /start: a short greeting prepended to the command // help, so a brand-new user immediately sees what the bot does and how to use // it. Like HelpText it never reaches the Runner — the duck greeting comes from // the model on a real message. -func WelcomeText(withLogin bool) string { - return "Hi! I'm the Flock assistant.\n\n" + HelpText(withLogin) +func WelcomeText(login chat.LoginVisibility) string { + return "Hi! I'm the Flock assistant.\n\n" + HelpText(login) } diff --git a/adapters/telegram/commands_test.go b/adapters/telegram/commands_test.go index 44ac497..d885d72 100644 --- a/adapters/telegram/commands_test.go +++ b/adapters/telegram/commands_test.go @@ -6,6 +6,8 @@ import ( "testing" "github.com/go-telegram/bot/models" + + "github.com/duckbugio/flock/core/chat" ) // botCmd builds a message whose leading token is a bot_command entity, the shape @@ -96,12 +98,12 @@ func TestStripCommandMention(t *testing.T) { // it can never submit a run); the constant IS the entire payload the handler // sends. func TestHelpTextListsCommands(t *testing.T) { - if HelpText(true) == "" { - t.Fatal("HelpText(true) is empty") + if HelpText(chat.WithLogin) == "" { + t.Fatal("HelpText(chat.WithLogin) is empty") } for _, cmd := range []string{"/help", "/new", "/stop"} { - if !strings.Contains(HelpText(true), cmd) { - t.Fatalf("HelpText(true) does not mention %q:\n%s", cmd, HelpText(true)) + if !strings.Contains(HelpText(chat.WithLogin), cmd) { + t.Fatalf("HelpText(chat.WithLogin) does not mention %q:\n%s", cmd, HelpText(chat.WithLogin)) } } } diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 b/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 index c560a59..d4a3cea 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 +++ b/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 @@ -33,7 +33,10 @@ CLAUDE_MAX_COST_PER_REQUEST={{ claude_max_cost_per_request }} # the one-time sign-in from the chat with /login IN A DIRECT MESSAGE — the reply # carries a one-time code that authorizes an account for the whole bot, so # starting a sign-in is refused in group chats. The bot posts a link + a -# one-time code. No shell on the host is needed. +# one-time code. No shell on the host is needed. Runs are blocked until the +# sign-in lands only when CODEX_REQUIRE_AUTH is true (set below); when it is +# false the bot runs immediately and an unfinished sign-in surfaces as a Codex +# CLI failure instead. # billing: requires CODEX_API_KEY and CODEX_BILLING_ACK=true. CODEX_BIN={{ codex_bin }} CODEX_MODEL={{ codex_model }} diff --git a/adapters/vk/commands.go b/adapters/vk/commands.go index a8b9f3d..d9dba9e 100644 --- a/adapters/vk/commands.go +++ b/adapters/vk/commands.go @@ -10,12 +10,12 @@ const helpTitle = "Flock VK assistant — available commands:\n\n" // HelpText renders the usage message. withLogin adds the /login line, on the same // condition that publishes /login in Telegram's command menu. It is an // engineering artifact (professional English, no duck flavor). -func HelpText(withLogin bool) string { return helpTitle + chat.HelpBody(withLogin) } +func HelpText(login chat.LoginVisibility) string { return helpTitle + chat.HelpBody(login) } // welcomeText is the reply to /start: a short greeting + the usage help, // mirroring the Telegram adapter's WelcomeText. -func welcomeText(withLogin bool) string { - return "Hi! I'm the Flock assistant.\n\n" + HelpText(withLogin) +func welcomeText(login chat.LoginVisibility) string { + return "Hi! I'm the Flock assistant.\n\n" + HelpText(login) } // goalUsageText is the /goal usage reply, mirroring the Telegram adapter. diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index aefe866..3b290f0 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -396,9 +396,9 @@ func (r *Receiver) dispatchReserved(ctx context.Context, name string, msg messag peerID := msg.PeerID switch name { case "start": - r.notify(ctx, peerID, welcomeText(r.auth.Applicable())) + r.notify(ctx, peerID, welcomeText(chat.LoginVisibilityFor(r.auth.Applicable()))) case "help": - r.notify(ctx, peerID, HelpText(r.auth.Applicable())) + r.notify(ctx, peerID, HelpText(chat.LoginVisibilityFor(r.auth.Applicable()))) case "new": if err := r.svc.NewSession(chatIDStr(peerID)); err != nil { r.logger.Error("vk: reset session", "peer_id", peerID, "error", err) diff --git a/adapters/vk/receiver_test.go b/adapters/vk/receiver_test.go index 2e3338e..13838e3 100644 --- a/adapters/vk/receiver_test.go +++ b/adapters/vk/receiver_test.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "errors" - "os" "path/filepath" "strings" "sync" @@ -15,6 +14,7 @@ import ( "github.com/duckbugio/flock/core/chat" "github.com/duckbugio/flock/core/codexauth" "github.com/duckbugio/flock/core/codexauth/codexauthtest" + "github.com/duckbugio/flock/core/codexauth/logintest" "github.com/duckbugio/flock/core/goal" "github.com/duckbugio/flock/core/schedule" ) @@ -368,7 +368,7 @@ func TestReceiverStartTextCommand(t *testing.T) { if len(svc.handleCalls) != 0 { t.Errorf("/start should not start a run, got %d Handle calls", len(svc.handleCalls)) } - if len(notices.texts) != 1 || notices.texts[0] != welcomeText(false) { + if len(notices.texts) != 1 || notices.texts[0] != welcomeText(chat.WithoutLogin) { t.Errorf("notice texts = %v, want one welcome notice", notices.texts) } } @@ -414,7 +414,7 @@ func TestReceiverHelpTextCommand(t *testing.T) { if len(svc.handleCalls) != 0 { t.Errorf("/help should not start a run, got %d Handle calls", len(svc.handleCalls)) } - if len(notices.texts) != 1 || notices.texts[0] != HelpText(false) { + if len(notices.texts) != 1 || notices.texts[0] != HelpText(chat.WithoutLogin) { t.Errorf("notice texts = %v, want one HelpText notice", notices.texts) } } @@ -708,28 +708,6 @@ func TestReceiverLoginWithoutManagerIsInert(t *testing.T) { } } -// pendingLoginManager returns a manager with a device login already in flight, -// its code issued, so a test can assert what each destination is allowed to see. -func pendingLoginManager(t *testing.T) *codexauth.Manager { - t.Helper() - m := codexauth.NewManager(codexauth.Config{ - Backend: codexauth.BackendCodex, - AuthMode: codexauth.AuthSubscription, - RequireAuth: true, - Home: t.TempDir(), - Bin: codexauthtest.WriteCLI(t, codexauthtest.Polling), - Env: []string{"PATH=" + os.Getenv("PATH")}, - }) - t.Cleanup(func() { m.Cancel() }) - - issued := make(chan string, 8) - m.Dispatch(context.Background(), "", codexauth.Subscriber{ - ID: "dm", Private: true, Notify: func(text string) { issued <- text }, - }) - codexauthtest.AwaitCode(t, issued) - return m -} - // TestReceiverLoginRefusedInConversation: the reply goes to the PEER, so in a // community conversation the verification link and one-time code would be // readable by every participant — and whoever acts on the code first binds THEIR @@ -758,7 +736,7 @@ func TestReceiverLoginRefusedInConversation(t *testing.T) { func TestReceiverLoginStatusInConversationHidesAPendingCode(t *testing.T) { svc := &fakeService{} notices := &fakeNotice{} - r := newTestReceiverWithAuth(svc, notices, pendingLoginManager(t)) + r := newTestReceiverWithAuth(svc, notices, logintest.PendingLogin(t)) r.dispatch(context.Background(), msgNewUpdate(t, messageObject{ FromID: 42, PeerID: 2000000001, Text: "/login status", @@ -781,7 +759,7 @@ func TestReceiverLoginStatusInConversationHidesAPendingCode(t *testing.T) { func TestReceiverLoginStatusInDirectMessageShowsTheCode(t *testing.T) { svc := &fakeService{} notices := &fakeNotice{} - r := newTestReceiverWithAuth(svc, notices, pendingLoginManager(t)) + r := newTestReceiverWithAuth(svc, notices, logintest.PendingLogin(t)) r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 42, Text: "/login status"})) @@ -810,13 +788,13 @@ func TestReceiverLoginRejectsDisallowedSender(t *testing.T) { // line appears only where an interactive sign-in exists, so the two adapters (and // Telegram's command menu) cannot disagree. func TestHelpListsLoginOnlyWhereItApplies(t *testing.T) { - if !strings.Contains(HelpText(true), "/login") { + if !strings.Contains(HelpText(chat.WithLogin), "/login") { t.Error("help omits /login where the sign-in is real") } - if strings.Contains(HelpText(false), "/login") { + if strings.Contains(HelpText(chat.WithoutLogin), "/login") { t.Error("help advertises /login where there is no sign-in") } - if strings.Contains(welcomeText(false), "/login") { + if strings.Contains(welcomeText(chat.WithoutLogin), "/login") { t.Error("welcome advertises /login where there is no sign-in") } } @@ -826,7 +804,7 @@ func TestHelpListsLoginOnlyWhereItApplies(t *testing.T) { // community peers private too, which is far too loose for the flag that decides // who may see a code that takes over the bot's account. func TestPrivacyFollowsTheDirectMessageInvariant(t *testing.T) { - auth := pendingLoginManager(t) + auth := logintest.PendingLogin(t) tests := []struct { name string @@ -864,7 +842,7 @@ func TestPrivacyFollowsTheDirectMessageInvariant(t *testing.T) { // drift silently — the adapters would still share the applicability predicate but // not the words. func TestHelpLineComesFromTheCanonicalSet(t *testing.T) { - if !strings.Contains(HelpText(true), chat.LoginHelpLine) { + if !strings.Contains(HelpText(chat.WithLogin), chat.LoginHelpLine) { t.Error("the VK help does not render the canonical /login line") } } diff --git a/cmd/flock-telegram/codex_login_test.go b/cmd/flock-telegram/codex_login_test.go index 8dc32b4..4215d02 100644 --- a/cmd/flock-telegram/codex_login_test.go +++ b/cmd/flock-telegram/codex_login_test.go @@ -15,10 +15,10 @@ import ( "github.com/go-telegram/bot/models" "github.com/duckbugio/flock/adapters/telegram" - "github.com/duckbugio/flock/adapters/vk" "github.com/duckbugio/flock/core/chat" "github.com/duckbugio/flock/core/codexauth" "github.com/duckbugio/flock/core/codexauth/codexauthtest" + "github.com/duckbugio/flock/core/codexauth/logintest" "github.com/duckbugio/flock/internal/config" ) @@ -211,7 +211,7 @@ func TestLoginRefusedInGroupChat(t *testing.T) { // argument-based guard left: /login status ALSO re-shows a pending one-time code, // so refusing only the code-starting arguments let it into the group anyway. func TestLoginStatusInGroupHidesAPendingCode(t *testing.T) { - auth := pendingLoginManager(t) + auth := logintest.PendingLogin(t) got := groupCommandReplies(t, auth, "/login status") if len(got) != 1 { @@ -229,7 +229,7 @@ func TestLoginStatusInGroupHidesAPendingCode(t *testing.T) { // message must still re-show the code — that is what makes a lost message // recoverable. func TestLoginStatusInPrivateShowsThePendingCode(t *testing.T) { - auth := pendingLoginManager(t) + auth := logintest.PendingLogin(t) cfg := config.Config{AllowedUsers: []int64{loginTestUserID}} replies := &capturingHTTPClient{} b := commandBot(t, cfg, auth, replies) @@ -243,28 +243,6 @@ func TestLoginStatusInPrivateShowsThePendingCode(t *testing.T) { } } -// pendingLoginManager returns a manager with a device login already in flight, -// its code issued, so a test can assert what each destination is allowed to see. -func pendingLoginManager(t *testing.T) *codexauth.Manager { - t.Helper() - m := codexauth.NewManager(codexauth.Config{ - Backend: codexauth.BackendCodex, - AuthMode: codexauth.AuthSubscription, - RequireAuth: true, - Home: t.TempDir(), - Bin: codexauthtest.WriteCLI(t, codexauthtest.Polling), - Env: []string{"PATH=" + os.Getenv("PATH")}, - }) - t.Cleanup(func() { m.Cancel() }) - - issued := make(chan string, 8) - m.Dispatch(context.Background(), "", codexauth.Subscriber{ - ID: "dm", Private: true, Notify: func(text string) { issued <- text }, - }) - codexauthtest.AwaitCode(t, issued) - return m -} - // commandBot builds a bot with the reserved handlers registered and every API // call answered locally by http. func commandBot(t *testing.T, cfg config.Config, auth *codexauth.Manager, client bot.HttpClient) *bot.Bot { @@ -389,16 +367,16 @@ func TestMenuKeepsLoginOnCodexSubscription(t *testing.T) { // text that still advertises it would send the user to a command that can only // answer "not needed". func TestHelpListsLoginOnlyWhereItApplies(t *testing.T) { - if !strings.Contains(telegram.HelpText(true), "/login") { + if !strings.Contains(telegram.HelpText(chat.WithLogin), "/login") { t.Error("help omits /login where the sign-in is real") } - if strings.Contains(telegram.HelpText(false), "/login") { + if strings.Contains(telegram.HelpText(chat.WithoutLogin), "/login") { t.Error("help advertises /login where there is no sign-in") } - if !strings.Contains(telegram.WelcomeText(true), "/login") { + if !strings.Contains(telegram.WelcomeText(chat.WithLogin), "/login") { t.Error("welcome omits /login where the sign-in is real") } - if strings.Contains(telegram.WelcomeText(false), "/login") { + if strings.Contains(telegram.WelcomeText(chat.WithoutLogin), "/login") { t.Error("welcome advertises /login where there is no sign-in") } @@ -409,7 +387,7 @@ func TestHelpListsLoginOnlyWhereItApplies(t *testing.T) { menuHasLogin = true } } - if menuHasLogin != strings.Contains(telegram.HelpText(codexAuthForMenu.Applicable()), "/login") { + if menuHasLogin != strings.Contains(telegram.HelpText(chat.LoginVisibilityFor(codexAuthForMenu.Applicable())), "/login") { t.Error("the command menu and /help disagree about /login") } } @@ -417,10 +395,7 @@ func TestHelpListsLoginOnlyWhereItApplies(t *testing.T) { // TestHelpLineComesFromTheCanonicalSet: both adapters render the same /login help // line from core/chat, so a wording change cannot land in one and not the other. func TestHelpLineComesFromTheCanonicalSet(t *testing.T) { - if !strings.Contains(telegram.HelpText(true), chat.LoginHelpLine) { + if !strings.Contains(telegram.HelpText(chat.WithLogin), chat.LoginHelpLine) { t.Error("the Telegram help does not render the canonical /login line") } - if !strings.Contains(vk.HelpText(true), chat.LoginHelpLine) { - t.Error("the VK help does not render the canonical /login line") - } } diff --git a/cmd/flock-telegram/commands_test.go b/cmd/flock-telegram/commands_test.go index 4c51483..030285e 100644 --- a/cmd/flock-telegram/commands_test.go +++ b/cmd/flock-telegram/commands_test.go @@ -129,10 +129,10 @@ func TestStartCommandRouted(t *testing.T) { // TestStartWelcomeText asserts the static /start reply is the welcome text: a // short greeting prepended to the usage help, plain English with no duck flavor. func TestStartWelcomeText(t *testing.T) { - if !strings.HasPrefix(telegram.WelcomeText(false), "Hi!") { - t.Fatalf("WelcomeText should open with a greeting, got %q", telegram.WelcomeText(false)) + if !strings.HasPrefix(telegram.WelcomeText(chat.WithoutLogin), "Hi!") { + t.Fatalf("WelcomeText should open with a greeting, got %q", telegram.WelcomeText(chat.WithoutLogin)) } - if !strings.Contains(telegram.WelcomeText(false), telegram.HelpText(false)) { + if !strings.Contains(telegram.WelcomeText(chat.WithoutLogin), telegram.HelpText(chat.WithoutLogin)) { t.Fatalf("WelcomeText should include the usage help (HelpText)") } } diff --git a/cmd/flock-telegram/main.go b/cmd/flock-telegram/main.go index 1884580..ca082a8 100644 --- a/cmd/flock-telegram/main.go +++ b/cmd/flock-telegram/main.go @@ -1079,7 +1079,7 @@ func startHandler(cfg config.Config, auth *codexauth.Manager) bot.HandlerFunc { if !ok { return } - sendCommandReply(ctx, b, chatID, telegram.WelcomeText(auth.Applicable())) + sendCommandReply(ctx, b, chatID, telegram.WelcomeText(chat.LoginVisibilityFor(auth.Applicable()))) } } @@ -1091,7 +1091,7 @@ func helpHandler(cfg config.Config, auth *codexauth.Manager) bot.HandlerFunc { if !ok { return } - sendCommandReply(ctx, b, chatID, telegram.HelpText(auth.Applicable())) + sendCommandReply(ctx, b, chatID, telegram.HelpText(chat.LoginVisibilityFor(auth.Applicable()))) } } diff --git a/core/chat/reserved.go b/core/chat/reserved.go index 90580c9..7a76beb 100644 --- a/core/chat/reserved.go +++ b/core/chat/reserved.go @@ -36,14 +36,36 @@ var ReservedCommands = []ReservedCommand{ // // withLogin adds the /login entry, on the same condition that publishes /login in // Telegram's command menu. -func HelpBody(withLogin bool) string { +func HelpBody(login LoginVisibility) string { body := helpCommands - if withLogin { + if login == WithLogin { body += LoginHelpLine } return body + helpTail } +// LoginVisibility says whether a rendered help text lists /login. It is a named +// type rather than a bare bool so a call site reads as its own documentation: +// HelpText(chat.WithLogin) instead of HelpText(true), which only means something +// after a trip to the declaration. +type LoginVisibility bool + +// Whether /login belongs in a help text. Derive it from the deployment with +// LoginVisibilityFor. +const ( + WithLogin LoginVisibility = true + WithoutLogin LoginVisibility = false +) + +// LoginVisibilityFor maps "does this deployment have an interactive sign-in" onto +// the flag, so callers do not convert a bool by hand. +func LoginVisibilityFor(applicable bool) LoginVisibility { + if applicable { + return WithLogin + } + return WithoutLogin +} + // helpCommands lists the reserved commands with the fuller wording a chat reply // affords, next to the terse Description the command menu uses. const helpCommands = "/help — show this message\n" + diff --git a/core/chat/reserved_test.go b/core/chat/reserved_test.go index 2d11681..898c9d0 100644 --- a/core/chat/reserved_test.go +++ b/core/chat/reserved_test.go @@ -96,8 +96,8 @@ func lower(s string) string { // share the applicability predicate but never the words, and would drift in // silence — the reason LoginHelpLine moved here in the first place. func TestHelpBodyIsTheSharedSource(t *testing.T) { - withLogin := chat.HelpBody(true) - without := chat.HelpBody(false) + withLogin := chat.HelpBody(chat.WithLogin) + without := chat.HelpBody(chat.WithoutLogin) if !strings.Contains(withLogin, chat.LoginHelpLine) { t.Error("HelpBody(true) omits the /login line") diff --git a/core/chat/rungate.go b/core/chat/rungate.go index bc11acf..819e86d 100644 --- a/core/chat/rungate.go +++ b/core/chat/rungate.go @@ -47,8 +47,12 @@ func (s *Service) blockSubmit(ctx context.Context, chatID ChatID) bool { } notice, blocked := s.auth.BlockedNotice() if !blocked { + // Clear EVERY chat, not just this one: authorization is process-wide, so one + // chat submitting is proof the outage is over for all of them. Clearing only + // the submitting chat would leave a chat that stayed quiet through the + // recovery window silently un-notified when the next lapse hit it. s.mu.Lock() - delete(s.authNotified, chatID) + clear(s.authNotified) s.mu.Unlock() return false } diff --git a/core/chat/rungate_test.go b/core/chat/rungate_test.go index a5c5a96..9ddedab 100644 --- a/core/chat/rungate_test.go +++ b/core/chat/rungate_test.go @@ -234,3 +234,26 @@ func TestBlockedScheduledFireIsReportedDropped(t *testing.T) { t.Error("InjectScheduled reported the fire enqueued while unauthorized") } } + +// TestNotifyResetIsProcessWide: authorization is process-wide, so one chat +// submitting after recovery proves the outage is over for all of them. Clearing +// only the submitting chat would leave a chat that stayed quiet through the +// recovery window silently un-notified when the next lapse hit it. +func TestNotifyResetIsProcessWide(t *testing.T) { + c, gate := newFakeChat(), blockingGate() + s := gatedService(t, &countingRunner{}, c, gate, newFakePending()) + + // The quiet chat is told once, then says nothing more. + s.Inject(testChatID, "first") + waitUntil(t, func() bool { return notices(c) == 1 }) + + // Recovery happens while ANOTHER chat submits; the quiet chat stays silent. + gate.blocked.Store(false) + s.Inject("other-chat", "now allowed") + waitUntil(t, func() bool { return gate.calls() >= 2 }) + + // The next lapse must be announced to the quiet chat again. + gate.blocked.Store(true) + s.Inject(testChatID, "blocked again") + waitUntil(t, func() bool { return notices(c) == 2 }) +} diff --git a/core/codexauth/codexauth.go b/core/codexauth/codexauth.go index 921e907..01b25ef 100644 --- a/core/codexauth/codexauth.go +++ b/core/codexauth/codexauth.go @@ -352,6 +352,13 @@ func (m *Manager) Dispatch(base context.Context, args string, sub Subscriber) st case "status": return m.statusText(sub.Private) case "cancel", "abort", "stop": + // The only state-MUTATING branch, so it obeys the same rule as start: a + // conversation could otherwise abort a sign-in running in someone else's + // direct message, and the confirmation would itself reveal that one is + // underway. Control of the login stays on the 1:1 channel that owns it. + if !sub.Private { + return LoginPrivateOnlyText + } if m.Cancel() { return "Cancelled the pending Codex login." } diff --git a/core/codexauth/codexauth_test.go b/core/codexauth/codexauth_test.go index 3e2751e..668c7a3 100644 --- a/core/codexauth/codexauth_test.go +++ b/core/codexauth/codexauth_test.go @@ -702,3 +702,36 @@ func TestGroupStatusHidesTheHostPath(t *testing.T) { t.Errorf("direct-message status = %q, want the path an operator needs", dm) } } + +// TestCancelIsPrivateOnly: cancel is the one state-MUTATING argument, so it obeys +// the same rule as starting. Otherwise a conversation could abort a sign-in +// running in someone else's direct message — and the confirmation would itself +// reveal that one is under way. +func TestCancelIsPrivateOnly(t *testing.T) { + bin := fakeCodex(t, printBanner+"sleep 30\n") + m := NewManager(Config{ + Backend: BackendCodex, + AuthMode: AuthSubscription, + RequireAuth: true, + Bin: bin, + Home: t.TempDir(), + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + t.Cleanup(func() { m.Cancel() }) + + c := newCollector() + m.Dispatch(context.Background(), "", c.sub("dm")) + c.awaitCode(t) + + got := m.Dispatch(context.Background(), "cancel", Subscriber{ID: "group"}) + if got != LoginPrivateOnlyText { + t.Errorf("group cancel = %q, want the direct-message refusal", got) + } + if !strings.Contains(m.StatusText(), "in progress") { + t.Error("a group aborted a sign-in owned by a direct message") + } + + if reply := m.Dispatch(context.Background(), "cancel", dmSub); !strings.Contains(reply, "Cancelled") { + t.Errorf("direct-message cancel = %q, want it to work", reply) + } +} diff --git a/core/codexauth/logintest/logintest.go b/core/codexauth/logintest/logintest.go new file mode 100644 index 0000000..dfad9d9 --- /dev/null +++ b/core/codexauth/logintest/logintest.go @@ -0,0 +1,46 @@ +// Package logintest builds a codexauth.Manager with a device login already in +// flight, for tests that need to assert what each kind of destination is allowed +// to see while a one-time code exists. +// +// It is separate from codexauthtest for one reason: this package imports +// codexauth, and codexauth's OWN tests import codexauthtest for the captured CLI +// banner. Putting this helper there would close that loop into an import cycle. +// So codexauthtest holds what everyone needs (the fixtures) and stays import-free +// of codexauth; this package holds what only the adapters need. +package logintest + +import ( + "context" + "os" + "testing" + + "github.com/duckbugio/flock/core/codexauth" + "github.com/duckbugio/flock/core/codexauth/codexauthtest" +) + +// noticeBuffer holds the notices a pending login delivers before the code, so a +// slow reader cannot block the manager's broadcast. +const noticeBuffer = 8 + +// PendingLogin returns a Manager whose device login is under way and whose +// one-time code has already been issued, with a scratch CODEX_HOME holding no +// credential. The login is cancelled when the test ends. +func PendingLogin(t *testing.T) *codexauth.Manager { + t.Helper() + m := codexauth.NewManager(codexauth.Config{ + Backend: codexauth.BackendCodex, + AuthMode: codexauth.AuthSubscription, + RequireAuth: true, + Home: t.TempDir(), + Bin: codexauthtest.WriteCLI(t, codexauthtest.Polling), + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + t.Cleanup(func() { m.Cancel() }) + + issued := make(chan string, noticeBuffer) + m.Dispatch(context.Background(), "", codexauth.Subscriber{ + ID: "dm", Private: true, Notify: func(text string) { issued <- text }, + }) + codexauthtest.AwaitCode(t, issued) + return m +} From 13cafa6e8cdf37d59e5c3f9bea4a0c5d84823e24 Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 14:17:03 +0300 Subject: [PATCH 09/21] fix(codex): hold a login attempt behind one pointer so its state cannot disagree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh review round on #66. Both findings were defects in the Manager's state machine, and one of them handed a user a code that had just been killed. MAJOR — Cancel's timeout left contradictory state. It cleared the cancel func but only run() cleared "running", and the drain is reachable in ordinary operation: the login goroutine can be inside a notice delivery, bounded by NotifyTimeout, which is longer than cancelDrain. A wedged transport therefore produced running=true with cancel=nil and a live prompt — so the next /login answered "already pending" and re-showed the code Cancel had just killed, while the next /login cancel said nothing was pending. MINOR — run() cleared "running" before broadcasting its verdict, and the broadcast read the subscriber list live rather than its own. A /login starting in that window reset the list, so the finished attempt announced its outcome to the SUCCESSOR's chat: that chat read a failure as its own seconds after "Starting the sign-in", and the chat that actually ran the login never heard anything. Both come from the same root: "is a login pending", "whose code is this" and "who is waiting for the outcome" were separate fields answering questions about one attempt, so they could drift apart. They now live in a session struct behind a single pointer. Swapping the pointer under the lock moves every answer at once: - Cancel detaches the attempt IMMEDIATELY, before waiting, so a drain timeout has nothing left to corrupt and a following /login starts fresh. Killing the child never depended on that wait anyway — cancelling the context makes Login signal the process group at once — so shutdown's orphan guarantee is unchanged. - run() detaches and snapshots its subscribers in one critical section, and only if it is still the current attempt, so a verdict reaches the destinations that were waiting for THAT login and cannot clear a successor's state. Also: a doc comment in the VK adapter still named a parameter that had been renamed. golangci-lint: 0 issues. Build, vet, full suite, and -race (including -count=2 on core/codexauth) green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/vk/commands.go | 2 +- core/chat/reserved.go | 2 +- core/codexauth/codexauth.go | 201 ++++++++++++++++++------------- core/codexauth/codexauth_test.go | 118 ++++++++++++++++++ 4 files changed, 237 insertions(+), 86 deletions(-) diff --git a/adapters/vk/commands.go b/adapters/vk/commands.go index d9dba9e..3332ad4 100644 --- a/adapters/vk/commands.go +++ b/adapters/vk/commands.go @@ -7,7 +7,7 @@ import "github.com/duckbugio/flock/core/chat" // slash-command UI, so these are plain text commands the receiver intercepts. const helpTitle = "Flock VK assistant — available commands:\n\n" -// HelpText renders the usage message. withLogin adds the /login line, on the same +// HelpText renders the usage message. login adds the /login line, on the same // condition that publishes /login in Telegram's command menu. It is an // engineering artifact (professional English, no duck flavor). func HelpText(login chat.LoginVisibility) string { return helpTitle + chat.HelpBody(login) } diff --git a/core/chat/reserved.go b/core/chat/reserved.go index 7a76beb..9864fb2 100644 --- a/core/chat/reserved.go +++ b/core/chat/reserved.go @@ -34,7 +34,7 @@ var ReservedCommands = []ReservedCommand{ // to stay in step with, for the same reason as LoginHelpLine: two byte-identical // copies share a predicate, never their words, and drift in silence. // -// withLogin adds the /login entry, on the same condition that publishes /login in +// login adds the /login entry, on the same condition that publishes /login in // Telegram's command menu. func HelpBody(login LoginVisibility) string { body := helpCommands diff --git a/core/codexauth/codexauth.go b/core/codexauth/codexauth.go index 01b25ef..59fe3e9 100644 --- a/core/codexauth/codexauth.go +++ b/core/codexauth/codexauth.go @@ -129,13 +129,27 @@ func (c Config) logger() *slog.Logger { type Manager struct { cfg Config - mu sync.Mutex - running bool - cancel context.CancelFunc - last Prompt - hasLast bool - subs []Subscriber - done chan struct{} // closed when the in-flight login goroutine has returned + mu sync.Mutex + // cur is the attempt in flight, nil when idle. Holding the whole attempt behind + // ONE pointer is what keeps the state machine honest: "is a login pending", + // "whose code is this", and "who is waiting to hear the outcome" are answers + // about one attempt, and swapping the pointer under the lock moves all of them + // at once. Kept as separate fields they could — and did — disagree: a Cancel + // that timed out cleared the cancel func but left the attempt marked running, + // so the next /login re-showed a code that had just been killed. + cur *session +} + +// session is one device-login attempt. The run goroutine owns its lifetime; every +// field is guarded by Manager.mu. +type session struct { + cancel context.CancelFunc + done chan struct{} // closed when this attempt's goroutine has returned + // prompt is the verification link and one-time code, once issued. + prompt Prompt + hasPrompt bool + // subs are the destinations waiting on THIS attempt's outcome. + subs []Subscriber } // NewManager returns a Manager for cfg. @@ -227,15 +241,22 @@ func (m *Manager) statusText(private bool) string { return m.notApplicableText() } m.mu.Lock() - running, last, hasLast := m.running, m.last, m.hasLast + cur := m.cur + var ( + prompt Prompt + hasPrompt bool + ) + if cur != nil { + prompt, hasPrompt = cur.prompt, cur.hasPrompt + } m.mu.Unlock() switch { - case running && hasLast && !private: + case cur != nil && hasPrompt && !private: return pendingElsewhereText - case running && hasLast: - return "Codex device login is in progress — finish it in the browser:\n\n" + promptText(last) - case running: + case cur != nil && hasPrompt: + return "Codex device login is in progress — finish it in the browser:\n\n" + promptText(prompt) + case cur != nil: return "Codex device login is starting; the link and code arrive in a moment." case m.cfg.HasAccessToken: return "Codex is authorized with a configured access token. No browser login needed." @@ -381,12 +402,12 @@ func (m *Manager) start(base context.Context, force bool, sub Subscriber) string return LoginPrivateOnlyText } m.mu.Lock() - if m.running { - m.subscribeLocked(sub) - last, hasLast := m.last, m.hasLast + if cur := m.cur; cur != nil { + subscribeLocked(cur, sub) + prompt, hasPrompt := cur.prompt, cur.hasPrompt m.mu.Unlock() - if hasLast { - return "A Codex login is already pending — finish this one:\n\n" + promptText(last) + if hasPrompt { + return "A Codex login is already pending — finish this one:\n\n" + promptText(prompt) } return "A Codex login is already starting; the link and code arrive in a moment." } @@ -400,15 +421,11 @@ func (m *Manager) start(base context.Context, force bool, sub Subscriber) string // context.WithoutCancel: the login must survive the update whose handler // started it. The timeout is the real bound. + //nolint:gosec // G118: cancel is owned by the session — run defers it, Cancel calls it. ctx, cancel := context.WithTimeout(context.WithoutCancel(base), m.cfg.timeout()) - done := make(chan struct{}) - m.running = true - m.cancel = cancel - m.done = done - m.hasLast = false - m.last = Prompt{} - m.subs = nil - m.subscribeLocked(sub) + cur := &session{cancel: cancel, done: make(chan struct{})} + subscribeLocked(cur, sub) + m.cur = cur m.mu.Unlock() // Delivered through the SAME channel as every later notice, and BEFORE the run @@ -417,7 +434,7 @@ func (m *Manager) start(base context.Context, force bool, sub Subscriber) string // out first, so the user read "the code arrives in a moment" underneath the // code that had already arrived. reply := m.ack(sub, "Starting the Codex sign-in — the link and one-time code arrive in a moment.") - go m.run(ctx, cancel, done) + go m.run(ctx, cur) return reply } @@ -433,106 +450,122 @@ func (m *Manager) ack(sub Subscriber, text string) string { return "" } -// subscribeLocked adds sub to the pending login's notice list, collapsing a -// destination that is already subscribed. The caller must hold m.mu. Only -// private destinations subscribe: the broadcast carries the one-time code. -func (m *Manager) subscribeLocked(sub Subscriber) { +// subscribeLocked adds sub to this attempt's notice list, collapsing a +// destination that is already subscribed. The caller must hold Manager.mu. Only +// private destinations subscribe: the notices carry the one-time code. +func subscribeLocked(s *session, sub Subscriber) { if sub.Notify == nil || !sub.Private { return } - for _, existing := range m.subs { + for _, existing := range s.subs { if existing.ID == sub.ID { return } } - m.subs = append(m.subs, sub) + s.subs = append(s.subs, sub) } -// broadcast delivers text to every current subscriber. The list is copied under -// the lock so a delivery (which does network I/O) never holds it. -func (m *Manager) broadcast(text string) { +// subscribers snapshots this attempt's destinations, so a delivery (which does +// network I/O) never holds the lock. +func (m *Manager) subscribers(s *session) []Subscriber { m.mu.Lock() - subs := append([]Subscriber(nil), m.subs...) - m.mu.Unlock() + defer m.mu.Unlock() + return append([]Subscriber(nil), s.subs...) +} + +// deliverAll sends text to every destination in subs. +func deliverAll(subs []Subscriber, text string) { for _, sub := range subs { sub.deliver(text) } } -// run drives one login attempt and reports its result to every subscriber. -// Closing done is what lets Cancel wait for the child to actually be gone. -func (m *Manager) run(ctx context.Context, cancel context.CancelFunc, done chan struct{}) { - defer close(done) - defer cancel() +// run drives one login attempt and reports its result to the destinations waiting +// on THAT attempt. Closing s.done is what lets Cancel wait for the goroutine to +// unwind. +func (m *Manager) run(ctx context.Context, s *session) { + defer close(s.done) + defer s.cancel() log := m.cfg.logger() err := Login(ctx, m.cfg, func(p Prompt) { m.mu.Lock() - m.last, m.hasLast = p, true + s.prompt, s.hasPrompt = p, true m.mu.Unlock() log.Info("codex device login prompt issued", "url", p.URL, "expires_in", p.ExpiresIn) - m.broadcast(promptText(p)) + deliverAll(m.subscribers(s), promptText(p)) }) + // Detach and snapshot in ONE critical section, and only if this attempt is + // still the current one. A cancelled attempt has already been detached, and a + // successor may be running by now: without the identity check this would clear + // the successor's state, and without the snapshot the verdict below would be + // delivered to whoever is subscribed at that instant — the successor's chat + // reading a failure as its own, the original never hearing its outcome. m.mu.Lock() - m.running = false - m.cancel = nil - m.done = nil + if m.cur == s { + m.cur = nil + } + subs := append([]Subscriber(nil), s.subs...) m.mu.Unlock() - if err == nil { - // A clean exit is necessary but not sufficient: what actually authorizes the - // deployment is the credential on disk, and that is what the run gate reads. - // Announcing success on the exit code alone would tell the user they are - // signed in and then refuse their very next message. - if !m.CredentialsPresent() { - log.Error("codex login exited cleanly but persisted no credential", "codex_home", m.cfg.Home) - m.broadcast("The Codex CLI finished the sign-in but saved no credential in " + m.cfg.Home + ".\n\n" + - "Check that the directory is writable, then send /login to try again.") - return - } - log.Info("codex device login succeeded", "codex_home", m.cfg.Home) - m.broadcast("Codex is authorized. Send your next message and the team gets to work.") - return - } - log.Warn("codex device login failed", "error", err) - m.broadcast(failureText(err)) + deliverAll(subs, m.outcome(err, log)) } -// cancelDrain bounds how long Cancel waits for the login child to be reaped. -// Login signals the process GROUP and escalates to SIGKILL after killGrace, so -// the real wait is milliseconds; the bound only stops shutdown from hanging on a -// pathological child. +// outcome turns a finished attempt into the text its subscribers are told, and +// logs it. +func (m *Manager) outcome(err error, log *slog.Logger) string { + if err != nil { + log.Warn("codex device login failed", "error", err) + return failureText(err) + } + // A clean exit is necessary but not sufficient: what actually authorizes the + // deployment is the credential on disk, and that is what the run gate reads. + // Announcing success on the exit code alone would tell the user they are + // signed in and then refuse their very next message. + if !m.CredentialsPresent() { + log.Error("codex login exited cleanly but persisted no credential", "codex_home", m.cfg.Home) + return "The Codex CLI finished the sign-in but saved no credential in " + m.cfg.Home + ".\n\n" + + "Check that the directory is writable, then send /login to try again." + } + log.Info("codex device login succeeded", "codex_home", m.cfg.Home) + return "Codex is authorized. Send your next message and the team gets to work." +} + +// cancelDrain bounds how long Cancel waits for the login goroutine to unwind. const cancelDrain = 5 * time.Second // Cancel aborts a pending login, reporting whether there was one. // -// It WAITS for the login goroutine to return rather than just cancelling its -// context. Two things depend on that. On shutdown, `defer auth.Cancel()` is the -// only thing that ends a login deliberately detached from every request context -// — returning before the child is signalled and reaped would leave an orphaned -// polling process, which is exactly what the defer exists to prevent. And a -// /login issued right after a cancel must not be answered with "already pending" -// plus a code that is already dead. +// The attempt is detached IMMEDIATELY, before any waiting: from the caller's next +// instruction the Manager is idle, so a /login that follows starts fresh instead +// of being answered "already pending" with the code just killed. The wait that +// follows is only for the goroutine to finish unwinding, and a timeout is +// therefore harmless — there is no state left to be inconsistent. That matters +// because the wait is genuinely reachable: the goroutine may be inside a notice +// delivery, which is bounded by NotifyTimeout, longer than cancelDrain. +// +// Killing the child does NOT depend on that wait either: cancelling the context +// makes Login signal the whole process group at once (SIGTERM, SIGKILL after +// killGrace). So `defer auth.Cancel()` on shutdown still guarantees no orphaned +// polling process, even when the drain times out. func (m *Manager) Cancel() bool { if m == nil { return false } m.mu.Lock() - if !m.running || m.cancel == nil { + s := m.cur + if s == nil { m.mu.Unlock() return false } - cancel, done := m.cancel, m.done - m.cancel = nil + m.cur = nil m.mu.Unlock() - cancel() - if done != nil { - select { - case <-done: - case <-time.After(cancelDrain): - } + s.cancel() + select { + case <-s.done: + case <-time.After(cancelDrain): } return true } diff --git a/core/codexauth/codexauth_test.go b/core/codexauth/codexauth_test.go index 668c7a3..66e0d4d 100644 --- a/core/codexauth/codexauth_test.go +++ b/core/codexauth/codexauth_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "testing" "time" ) @@ -735,3 +736,120 @@ func TestCancelIsPrivateOnly(t *testing.T) { t.Errorf("direct-message cancel = %q, want it to work", reply) } } + +// TestCancelLeavesNoPendingStateEvenOnTimeout is the state-machine regression. +// The drain is reachable in normal operation — the login goroutine may be inside +// a notice delivery, bounded by NotifyTimeout, which is longer than cancelDrain. +// When that happened, Cancel cleared the cancel func but left the attempt marked +// running: the next /login re-showed the code it had just killed, and the next +// /login cancel answered "nothing is pending". The attempt is now detached before +// any waiting, so a timeout leaves nothing behind. +func TestCancelLeavesNoPendingStateEvenOnTimeout(t *testing.T) { + bin := fakeCodex(t, printBanner+"sleep 30\n") + m := NewManager(Config{ + Backend: BackendCodex, + AuthMode: AuthSubscription, + RequireAuth: true, + Bin: bin, + Home: t.TempDir(), + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + t.Cleanup(func() { m.Cancel() }) + + // A subscriber that takes the acknowledgement and then wedges on the prompt, + // standing in for a transport that drops into a 429 back-off. The login + // goroutine is thus stuck inside a delivery — which is what makes the drain + // reachable, since a delivery is bounded by NotifyTimeout, not by cancelDrain. + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + var delivered atomic.Int64 + stuck := Subscriber{ID: "dm", Private: true, Notify: func(string) { + if delivered.Add(1) > 1 { + <-release + } + }} + + m.Dispatch(context.Background(), "", stuck) + waitFor(t, func() bool { return delivered.Load() > 1 }) + + if !m.Cancel() { + t.Fatal("Cancel reported nothing pending") + } + if got := m.StatusText(); strings.Contains(got, "in progress") { + t.Errorf("StatusText = %q after Cancel; the attempt was left marked pending", got) + } + if m.Cancel() { + t.Error("a second Cancel found something pending") + } + // The decisive symptom: a fresh /login must not be answered with the dead code. + if got := m.Dispatch(context.Background(), "", dmSub); strings.Contains(got, "already pending") { + t.Errorf("a fresh /login after cancel = %q, want a new attempt", got) + } +} + +// TestOutcomeGoesToTheAttemptThatProducedIt: the verdict is delivered to a +// snapshot of THIS attempt's subscribers. Reading the live list instead let a +// successor's chat receive the predecessor's failure — seconds after its own +// "Starting the sign-in" — while the chat that actually ran it heard nothing. +func TestOutcomeGoesToTheAttemptThatProducedIt(t *testing.T) { + m := NewManager(Config{ + Backend: BackendCodex, + AuthMode: AuthSubscription, + RequireAuth: true, + Bin: fakeCodex(t, printBanner+"sleep 30\n"), + Home: t.TempDir(), + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + t.Cleanup(func() { m.Cancel() }) + + first, second := newCollector(), newCollector() + m.Dispatch(context.Background(), "", first.sub("chat-a")) + first.awaitCode(t) + + // Cancel detaches attempt A; B starts while A is still unwinding. + m.Cancel() + m.Dispatch(context.Background(), "", second.sub("chat-b")) + + // A's cancellation reaches A, and never B. + found := false + for range 3 { + if strings.Contains(strings.ToLower(first.next(t)), "cancelled") { + found = true + break + } + } + if !found { + t.Error("the cancelled attempt's own chat never heard its outcome") + } + for _, text := range drain(second) { + if strings.Contains(strings.ToLower(text), "cancelled") { + t.Errorf("the successor's chat received the predecessor's verdict: %q", text) + } + } +} + +// drain returns every notice delivered so far, without blocking. +func drain(c *collector) []string { + var out []string + for { + select { + case text := <-c.ch: + out = append(out, text) + default: + return out + } + } +} + +// waitFor polls cond until it holds or the test times out. +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("condition not met before deadline") +} From caac95f879800283a9886cd56fccb18271bdf7c8 Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 14:34:39 +0300 Subject: [PATCH 10/21] fix(codex): say where /login works, and never leave a sign-in silent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighth review round on #66. Two ordering/secrecy defects and three texts that sent users somewhere the command would be refused. - The blocked-run notice said "send /login". In a group that earns a second refusal — possibly in the only chat the user has with the bot. The gate cannot see the destination, so the text now names one that always works: send it in a direct message. Same for the /help line and the published menu description, where the requirement that decides whether the command works at all was missing entirely. - start()'s "already pending" replies were returned for the adapter to send while the caller was already subscribed a line above, so a broadcast could overtake them — the exact race the fresh-start branch next to it fixes with ack. Both now go through ack too. - The prompt log line carried the verification URL, while redact() strips URLs from captured output for the stated reason that the link pairs with the code. Codex may also emit verification_uri_complete, which embeds the code in the link. Dropped; expires_in stays. - .env.example said /login "is refused in group chats". Only STARTING one is (and cancel); status answers, withholding the code. env.j2 already worded this correctly. And the degradation the reviewer flagged: if the parser never pairs a URL with a code — a changed banner, a code with an unexpected prefix — the user read "the link and one-time code arrive in a moment" and then heard nothing for DeviceCodeTTL. That is this package's own symptom wearing friendlier wording. A 45s guard now reports the silence and names the way out. golangci-lint: 0 issues. Build, vet, full suite and -race green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/telegram/.env.example | 2 +- core/chat/reserved.go | 5 +- core/chat/reserved_test.go | 17 +++++ core/codexauth/codexauth.go | 45 +++++++++++-- core/codexauth/codexauth_test.go | 110 ++++++++++++++++++++++++++++--- 5 files changed, 164 insertions(+), 15 deletions(-) diff --git a/adapters/telegram/.env.example b/adapters/telegram/.env.example index beb0859..ec76570 100644 --- a/adapters/telegram/.env.example +++ b/adapters/telegram/.env.example @@ -23,7 +23,7 @@ AI_BACKEND=claude # claude | codex | openai-compatible # # First-time sign-in needs no shell on the host: the bot STARTS unauthorized and # an allow-listed user sends /login in a DIRECT MESSAGE (the reply carries a -# one-time code, so it is refused in group chats). The bot replies with a +# one-time code, so starting a sign-in is refused in group chats). The bot replies with a # verification link and a one-time code (this is `codex login --device-auth`; # plain `codex login` would open a loopback server on localhost:1455 that nothing # outside the container can reach, which is why it appears to hang). Open the diff --git a/core/chat/reserved.go b/core/chat/reserved.go index 9864fb2..167b494 100644 --- a/core/chat/reserved.go +++ b/core/chat/reserved.go @@ -25,7 +25,7 @@ var ReservedCommands = []ReservedCommand{ {Name: "stop", Description: "Stop the run currently in progress"}, {Name: "schedule", Description: "Manage scheduled jobs"}, {Name: "goal", Description: "Arm a goal an independent evaluator re-checks after every run"}, - {Name: "login", Description: "Sign in to Codex on a subscription (other backends need no login)"}, + {Name: "login", Description: "Sign in to Codex on a subscription (send it in a direct message)"}, } // HelpBody is the shared body of both adapters' /help: the command list and the @@ -84,7 +84,8 @@ const helpTail = "\nSend any other message to run it through the assistant." // applicability predicate, not the words. Adapters append it only when an // interactive sign-in exists, the same condition that publishes /login in // Telegram's command menu. -const LoginHelpLine = "/login — sign in to Codex on a subscription (/login status, /login cancel)\n" +const LoginHelpLine = "/login — sign in to Codex on a subscription; send it in a direct message " + + "(/login status, /login cancel)\n" // IsReservedCommand reports whether name is one of the bot's reserved commands. // The match is case-insensitive (name is normalized to lower case) and exact: diff --git a/core/chat/reserved_test.go b/core/chat/reserved_test.go index 898c9d0..ab4a263 100644 --- a/core/chat/reserved_test.go +++ b/core/chat/reserved_test.go @@ -115,3 +115,20 @@ func TestHelpBodyIsTheSharedSource(t *testing.T) { } } } + +// TestLoginHelpLineNamesTheDirectMessageRequirement: this line is rendered in +// group chats and published in Telegram's global command menu, so the one +// requirement that decides whether the command works at all belongs in it. +func TestLoginHelpLineNamesTheDirectMessageRequirement(t *testing.T) { + if !strings.Contains(chat.LoginHelpLine, "direct message") { + t.Errorf("LoginHelpLine = %q, want the direct-message requirement", chat.LoginHelpLine) + } + for _, c := range chat.ReservedCommands { + if c.Name != "login" { + continue + } + if !strings.Contains(c.Description, "direct message") { + t.Errorf("the menu description %q omits the direct-message requirement", c.Description) + } + } +} diff --git a/core/codexauth/codexauth.go b/core/codexauth/codexauth.go index 59fe3e9..6d09294 100644 --- a/core/codexauth/codexauth.go +++ b/core/codexauth/codexauth.go @@ -219,8 +219,12 @@ func (m *Manager) BlockedNotice() (string, bool) { if m == nil || m.Authorized() { return "", false } + // "in a direct message", because that is where the command will be accepted: + // this gate cannot see the destination, and sending the user to /login in a + // group would only earn them a second refusal — possibly in the only chat they + // use with the bot. return "Codex is not authorized yet, so I can't run anything.\n\n" + - "Send /login and I'll walk you through the one-time browser sign-in.", true + "Send /login in a direct message with me and I'll walk you through the one-time browser sign-in.", true } // NoLoginNeededText is the reply when this deployment has no interactive login @@ -406,10 +410,14 @@ func (m *Manager) start(base context.Context, force bool, sub Subscriber) string subscribeLocked(cur, sub) prompt, hasPrompt := cur.prompt, cur.hasPrompt m.mu.Unlock() + // Through ack for the same reason the fresh-start branch below uses it: this + // caller is already subscribed (a line above), so a reply the ADAPTER sends + // can be overtaken by a broadcast — leaving "the code arrives in a moment" + // sitting under a code that already arrived. if hasPrompt { - return "A Codex login is already pending — finish this one:\n\n" + promptText(prompt) + return m.ack(sub, "A Codex login is already pending — finish this one:\n\n"+promptText(prompt)) } - return "A Codex login is already starting; the link and code arrive in a moment." + return m.ack(sub, "A Codex login is already starting; the link and code arrive in a moment.") } // CredentialsPresent, not Authorized: with CODEX_REQUIRE_AUTH=false the policy // answer is always "authorized", which would make /login unusable on exactly @@ -488,11 +496,32 @@ func (m *Manager) run(ctx context.Context, s *session) { defer s.cancel() log := m.cfg.logger() + // Guard against the very symptom this package exists to remove. If the parser + // never pairs a URL with a code — a changed banner, a code printed with an + // unexpected prefix — the user would otherwise read "the link and one-time code + // arrive in a moment" and then hear nothing for DeviceCodeTTL, which is exactly + // "it just hangs" wearing a friendlier hat. Say so instead, and name the way out. + warn := time.AfterFunc(promptWarnAfter, func() { + m.mu.Lock() + silent := m.cur == s && !s.hasPrompt + subs := append([]Subscriber(nil), s.subs...) + m.mu.Unlock() + if !silent { + return + } + log.Warn("codex printed no device-login code yet", "after", promptWarnAfter) + deliverAll(subs, noPromptYetText) + }) + defer warn.Stop() + err := Login(ctx, m.cfg, func(p Prompt) { m.mu.Lock() s.prompt, s.hasPrompt = p, true m.mu.Unlock() - log.Info("codex device login prompt issued", "url", p.URL, "expires_in", p.ExpiresIn) + // No URL: it pairs with the code, which is why redact strips it from captured + // output. Codex may also start emitting verification_uri_complete, which + // embeds the code in the link itself. + log.Info("codex device login prompt issued", "expires_in", p.ExpiresIn) deliverAll(m.subscribers(s), promptText(p)) }) @@ -532,6 +561,14 @@ func (m *Manager) outcome(err error, log *slog.Logger) string { return "Codex is authorized. Send your next message and the team gets to work." } +// promptWarnAfter is how long the CLI may stay silent before the user is told +// that no code has appeared. A variable so tests need not wait it out. +var promptWarnAfter = 45 * time.Second + +// noPromptYetText reports a sign-in that has produced no code yet. +const noPromptYetText = "The Codex CLI still hasn't printed a sign-in code.\n\n" + + "It may just be slow to start. If nothing arrives shortly, /login cancel stops it and /login tries again." + // cancelDrain bounds how long Cancel waits for the login goroutine to unwind. const cancelDrain = 5 * time.Second diff --git a/core/codexauth/codexauth_test.go b/core/codexauth/codexauth_test.go index 66e0d4d..f057632 100644 --- a/core/codexauth/codexauth_test.go +++ b/core/codexauth/codexauth_test.go @@ -274,12 +274,17 @@ func TestDispatchReshowsPendingPrompt(t *testing.T) { m.Dispatch(context.Background(), "", c.sub("chat-1")) c.awaitCode(t) - again := m.Dispatch(context.Background(), "", c.sub("chat-1")) - if !strings.Contains(again, "XER9-NWCA2") { - t.Errorf("second /login = %q, want the pending code re-shown", again) + // The re-show goes through the subscriber (so a broadcast cannot overtake it), + // which is why Dispatch itself returns nothing for the adapter to send. + if again := m.Dispatch(context.Background(), "", c.sub("chat-1")); again != "" { + t.Errorf("second /login returned %q, want it delivered through the subscriber", again) } - if !strings.Contains(again, "already pending") { - t.Errorf("second /login = %q, want it to say a login is already pending", again) + reshow := c.next(t) + if !strings.Contains(reshow, deviceCode) { + t.Errorf("re-show = %q, want the pending code", reshow) + } + if !strings.Contains(reshow, "already pending") { + t.Errorf("re-show = %q, want it to say a login is already pending", reshow) } //nolint:gosec // the path is a test-owned temp file @@ -567,10 +572,12 @@ func TestEveryAskerLearnsTheOutcome(t *testing.T) { m.Dispatch(context.Background(), "", first.sub("chat-1")) first.awaitCode(t) - // A different chat asks while the login is pending. - if again := m.Dispatch(context.Background(), "", second.sub("chat-2")); !strings.Contains(again, "XER9-NWCA2") { - t.Fatalf("second /login = %q, want the pending code re-shown", again) + // A different chat asks while the login is pending and is re-shown the code + // through its own subscription. + if again := m.Dispatch(context.Background(), "", second.sub("chat-2")); again != "" { + t.Fatalf("second /login returned %q, want it delivered through the subscriber", again) } + second.awaitCode(t) for name, c := range map[string]*collector{"starter": first, "joiner": second} { if got := c.next(t); !strings.Contains(strings.ToLower(got), "authorized") { @@ -595,7 +602,10 @@ func TestTheSameChatAskingTwiceIsNotToldTwice(t *testing.T) { c := newCollector() m.Dispatch(context.Background(), "", c.sub("chat-1")) c.awaitCode(t) + // Asking again re-shows the code to the asker — that is what asking is for — + // but must not double every notice that FOLLOWS. m.Dispatch(context.Background(), "", c.sub("chat-1")) + c.awaitCode(t) if got := c.next(t); !strings.Contains(strings.ToLower(got), "authorized") { t.Fatalf("final notice = %q, want the success confirmation", got) @@ -853,3 +863,87 @@ func waitFor(t *testing.T, cond func() bool) { } t.Fatal("condition not met before deadline") } + +// TestSilenceWithoutACodeIsReported guards against the package's own failure +// mode returning by the back door. If the parser never pairs a URL with a code — +// a changed banner, a code printed with an unexpected prefix — the user would +// read "the link and one-time code arrive in a moment" and then hear nothing for +// DeviceCodeTTL. That is "it just hangs" with friendlier wording, and it is the +// symptom this package exists to remove. +func TestSilenceWithoutACodeIsReported(t *testing.T) { + restore := promptWarnAfter + promptWarnAfter = 50 * time.Millisecond + t.Cleanup(func() { promptWarnAfter = restore }) + + // A CLI that prints something unparseable and then polls, as a drifted banner + // would. + bin := fakeCodex(t, "echo 'signing you in, please hold'\nsleep 30\n") + m := NewManager(Config{ + Backend: BackendCodex, + AuthMode: AuthSubscription, + RequireAuth: true, + Bin: bin, + Home: t.TempDir(), + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + t.Cleanup(func() { m.Cancel() }) + + c := newCollector() + m.Dispatch(context.Background(), "", c.sub("dm")) + if ack := c.next(t); !strings.Contains(ack, "Starting") { + t.Fatalf("first notice = %q, want the acknowledgement", ack) + } + + got := c.next(t) + if !strings.Contains(got, "hasn't printed a sign-in code") { + t.Errorf("notice = %q, want the no-code warning", got) + } + if !strings.Contains(got, "/login cancel") { + t.Errorf("notice = %q, want it to name the way out", got) + } +} + +// TestNoSilenceWarningOnceTheCodeArrives: the guard must stay quiet on the happy +// path, or every login would carry a spurious "no code yet". +func TestNoSilenceWarningOnceTheCodeArrives(t *testing.T) { + restore := promptWarnAfter + promptWarnAfter = 50 * time.Millisecond + t.Cleanup(func() { promptWarnAfter = restore }) + + m := NewManager(Config{ + Backend: BackendCodex, + AuthMode: AuthSubscription, + RequireAuth: true, + Bin: fakeCodex(t, printBanner+"sleep 30\n"), + Home: t.TempDir(), + Env: []string{"PATH=" + os.Getenv("PATH")}, + }) + t.Cleanup(func() { m.Cancel() }) + + c := newCollector() + m.Dispatch(context.Background(), "", c.sub("dm")) + c.awaitCode(t) + + select { + case extra := <-c.ch: + t.Errorf("a warning fired after the code arrived: %q", extra) + case <-time.After(300 * time.Millisecond): + } +} + +// TestBlockedNoticePointsAtADirectMessage: the run gate cannot see the +// destination, so the notice it sends must be correct in a group too — telling a +// user to send /login where it will be refused earns them two refusals in a row, +// possibly in the only chat they use with the bot. +func TestBlockedNoticePointsAtADirectMessage(t *testing.T) { + m := NewManager(Config{ + Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: t.TempDir(), + }) + notice, blocked := m.BlockedNotice() + if !blocked { + t.Fatal("BlockedNotice reported no block while unauthorized") + } + if !strings.Contains(notice, "direct message") { + t.Errorf("notice = %q, want it to name where /login is accepted", notice) + } +} From f8bdcd1c7127060a010e23ca7c506f863df9aa0e Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 14:56:12 +0300 Subject: [PATCH 11/21] fix(codex): keep /login off the VK poll loop, and answer only real messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ninth review round on #66. - VK processes updates SERIALLY inside its poll loop, and /login can block for seconds: Cancel waits out the login goroutine (up to cancelDrain) and every reply is a transport call bounded by NotifyTimeout. Handling it inline stalled messages in every chat. It now runs detached, which is safe because the login was already detached and each notice builds its own context. I had defended the blocking Cancel on correctness grounds last round; the reviewer was right that the fix belongs at the call site instead. - The unauthorized notice answered messages that would never have started a run — a sticker, an empty message, an unsupported attachment — which are dropped silently on a healthy deployment. Both adapters now check there is work first, still ahead of any paid work. - pendingElsewhereText claimed the code "went to the direct message that started it — send /login there". It is in fact re-shown in ANY direct message: one Codex identity serves the deployment, so any allow-listed user can finish a sign-in someone else began. That is deliberate, but the message said the opposite; it is now neutral about the addressee, and both env files state the property outright. - Both adapter test helpers built a Manager with no Bin, so a future /login case from a direct message would have run the REAL codex and polled for the whole DeviceCodeTTL. They now point at an absent binary, as one Telegram case already did. The CI failure on the previous push was TestProgressEditsRespectMinInterval, which this branch does not touch: it is documented as starvation-prone under a loaded runner, and passes 5/5 locally. A re-run went green. golangci-lint: 0 issues. Build, vet, full suite and -race (with -count=2 on the packages this touches) green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/telegram/.env.example | 7 +- .../roles/claude_tg_bot/templates/env.j2 | 5 +- adapters/vk/receiver.go | 28 +++- adapters/vk/receiver_test.go | 127 +++++++++++++++--- cmd/flock-telegram/codex_login_test.go | 3 + cmd/flock-telegram/main.go | 8 +- core/codexauth/codexauth.go | 11 +- 7 files changed, 160 insertions(+), 29 deletions(-) diff --git a/adapters/telegram/.env.example b/adapters/telegram/.env.example index ec76570..cc88d33 100644 --- a/adapters/telegram/.env.example +++ b/adapters/telegram/.env.example @@ -23,13 +23,16 @@ AI_BACKEND=claude # claude | codex | openai-compatible # # First-time sign-in needs no shell on the host: the bot STARTS unauthorized and # an allow-listed user sends /login in a DIRECT MESSAGE (the reply carries a -# one-time code, so starting a sign-in is refused in group chats). The bot replies with a -# verification link and a one-time code (this is `codex login --device-auth`; +# one-time code, so starting a sign-in is refused in group chats). The bot +# replies with a verification link and a one-time code (this is `codex login --device-auth`; # plain `codex login` would open a loopback server on localhost:1455 that nothing # outside the container can reach, which is why it appears to hang). Open the # link, enter the code, and the bot confirms. With CODEX_REQUIRE_AUTH=true (the # default below) runs are blocked until then; with it false the bot runs # immediately and an unfinished sign-in surfaces as a Codex CLI failure instead. +# Any allow-listed user can finish a sign-in already under way: the code is +# re-shown in any direct message, and whoever completes it becomes the +# deployment's single Codex identity. CODEX_AUTH_MODE=subscription # subscription | billing CODEX_BIN=codex CODEX_HOME=/home/claude/.codex diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 b/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 index d4a3cea..abb9e9e 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 +++ b/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 @@ -33,7 +33,10 @@ CLAUDE_MAX_COST_PER_REQUEST={{ claude_max_cost_per_request }} # the one-time sign-in from the chat with /login IN A DIRECT MESSAGE — the reply # carries a one-time code that authorizes an account for the whole bot, so # starting a sign-in is refused in group chats. The bot posts a link + a -# one-time code. No shell on the host is needed. Runs are blocked until the +# one-time code. No shell on the host is needed. +# Any allow-listed user can finish a sign-in already under way — the code is +# re-shown in any direct message, and whoever completes it becomes the +# deployment's single Codex identity. Runs are blocked until the # sign-in lands only when CODEX_REQUIRE_AUTH is true (set below); when it is # false the bot runs immediately and an unfinished sign-in surfaces as a Codex # CLI failure instead. diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index 3b290f0..dace5b9 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -345,7 +345,12 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { // nothing, while transcription and downloads do. /login is dispatched above and // stays reachable while this gate is closed. core/chat gates the run itself; // this is the early, user-facing half. - if notice, blocked := r.auth.BlockedNotice(); blocked { + // + // Only for a message that WOULD have started a run: a sticker, an empty message + // or an unsupported attachment is dropped silently further down, and answering + // those with "not authorized" spends rate limit to tell the user about work they + // never asked for. + if notice, blocked := r.auth.BlockedNotice(); blocked && r.hasWork(cleaned, msg) { r.logger.Debug("vk: codex unauthorized — blocking run", "peer_id", peerID) r.notify(ctx, peerID, notice) return @@ -385,6 +390,19 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { r.svc.Handle(ctx, chatIDStr(peerID), msg.FromID, msgIDStr(msg.ConversationMessageID), text) } +// hasWork reports whether this message carries something the receiver would +// actually submit: text (after the mention gate stripped the address), a voice +// note to transcribe, or an attachment to save. It mirrors the branches below. +func (r *Receiver) hasWork(cleaned string, msg messageObject) bool { + if strings.TrimSpace(cleaned) != "" { + return true + } + if r.voice != nil && firstAudioMessage(msg.Attachments) != nil { + return true + } + return r.uploads != nil && (firstDoc(msg.Attachments) != nil || firstPhoto(msg.Attachments) != nil) +} + // dispatchReserved acts on a reserved command (caller already confirmed the name // is in the canonical set). /start and /help reply with the static notices; /new // resets the chat's session and confirms; /stop cancels the in-flight run; @@ -411,7 +429,13 @@ func (r *Receiver) dispatchReserved(ctx context.Context, name string, msg messag case "goal": r.dispatchGoal(ctx, msg) case "login": - r.dispatchLogin(ctx, msg) + // On its own goroutine: this receiver processes updates SERIALLY inside the + // poll loop, and /login can block for seconds — Cancel waits out the login + // goroutine (up to cancelDrain) and each reply is a transport call bounded by + // NotifyTimeout. Blocking there would stall every chat's messages. Detaching + // is safe because the login itself is already detached (Dispatch derives its + // own context) and every notice builds its own. + go r.dispatchLogin(ctx, msg) } } diff --git a/adapters/vk/receiver_test.go b/adapters/vk/receiver_test.go index 13838e3..f73878e 100644 --- a/adapters/vk/receiver_test.go +++ b/adapters/vk/receiver_test.go @@ -9,6 +9,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/duckbugio/flock/core/agent" "github.com/duckbugio/flock/core/chat" @@ -117,6 +118,29 @@ func (n *fakeNotice) Notify(_ context.Context, _ int64, text string) { n.texts = append(n.texts, text) } +// seen snapshots the notices delivered so far. /login is dispatched on its own +// goroutine (it must not block the poll loop), so its tests read through this +// rather than touching the slice. +func (n *fakeNotice) seen() []string { + n.mu.Lock() + defer n.mu.Unlock() + return append([]string(nil), n.texts...) +} + +// await waits for at least one notice to arrive and returns everything seen. +func (n *fakeNotice) await(t *testing.T) []string { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if got := n.seen(); len(got) > 0 { + return got + } + time.Sleep(2 * time.Millisecond) + } + t.Fatal("no notice arrived") + return nil +} + // newTestReceiver builds a Receiver wired to the fakes, allowing user 42, group // 123, with the given requireMention. func newTestReceiver(svc Service, notices NoticeSender, requireMention bool, acked *[]string) *Receiver { @@ -646,11 +670,17 @@ func isContextErr(err error) bool { // own plumbing instead of reaching past it into the private field. func unauthorizedCodexReceiver(t *testing.T, svc Service, notices NoticeSender) *Receiver { t.Helper() + home := t.TempDir() return newTestReceiverWithAuth(svc, notices, codexauth.NewManager(codexauth.Config{ Backend: codexauth.BackendCodex, AuthMode: codexauth.AuthSubscription, RequireAuth: true, - Home: t.TempDir(), + Home: home, + // A deliberately absent binary. No case here reaches a sign-in start today, + // but that is a property of the current set: one future /login from a direct + // message would otherwise run the REAL codex on a developer's machine and sit + // polling for the full DeviceCodeTTL. + Bin: filepath.Join(home, "no-such-codex"), })) } @@ -684,8 +714,8 @@ func TestReceiverLoginCommandStaysReachableWhileUnauthorized(t *testing.T) { if len(svc.handleCalls) != 0 { t.Errorf("/login should not start a run, got %d Handle calls", len(svc.handleCalls)) } - if len(notices.texts) != 1 || !strings.Contains(notices.texts[0], "NOT authorized") { - t.Errorf("notices = %v, want the login status reply", notices.texts) + if len(notices.await(t)) != 1 || !strings.Contains(notices.await(t)[0], "NOT authorized") { + t.Errorf("notices = %v, want the login status reply", notices.seen()) } } @@ -698,8 +728,8 @@ func TestReceiverLoginWithoutManagerIsInert(t *testing.T) { r := newTestReceiver(svc, notices, false, nil) r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 42, Text: "/login"})) - if len(notices.texts) != 1 || notices.texts[0] != codexauth.NoLoginNeededText { - t.Errorf("notices = %v, want the no-login-needed reply", notices.texts) + if len(notices.await(t)) != 1 || notices.await(t)[0] != codexauth.NoLoginNeededText { + t.Errorf("notices = %v, want the no-login-needed reply", notices.seen()) } r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 200, Text: "build it"})) @@ -720,10 +750,10 @@ func TestReceiverLoginRefusedInConversation(t *testing.T) { // 2000000000+ is VK's conversation (chat) peer range. r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 2000000001, Text: "/login"})) - if len(notices.texts) != 1 || !strings.Contains(notices.texts[0], "direct message") { - t.Fatalf("notices = %v, want the refusal pointing at a direct message", notices.texts) + if len(notices.await(t)) != 1 || !strings.Contains(notices.await(t)[0], "direct message") { + t.Fatalf("notices = %v, want the refusal pointing at a direct message", notices.seen()) } - if strings.Contains(notices.texts[0], "http") { + if strings.Contains(notices.await(t)[0], "http") { t.Error("the refusal leaked a link into the conversation") } } @@ -742,14 +772,14 @@ func TestReceiverLoginStatusInConversationHidesAPendingCode(t *testing.T) { FromID: 42, PeerID: 2000000001, Text: "/login status", })) - if len(notices.texts) != 1 { - t.Fatalf("notices = %v, want exactly one", notices.texts) + if len(notices.await(t)) != 1 { + t.Fatalf("notices = %v, want exactly one", notices.seen()) } - if strings.Contains(notices.texts[0], codexauthtest.DeviceCode) || strings.Contains(notices.texts[0], "http") { - t.Errorf("the pending code or link reached a conversation: %q", notices.texts[0]) + if strings.Contains(notices.await(t)[0], codexauthtest.DeviceCode) || strings.Contains(notices.await(t)[0], "http") { + t.Errorf("the pending code or link reached a conversation: %q", notices.await(t)[0]) } - if !strings.Contains(notices.texts[0], "in progress") { - t.Errorf("status = %q, want it to still report the pending sign-in", notices.texts[0]) + if !strings.Contains(notices.await(t)[0], "in progress") { + t.Errorf("status = %q, want it to still report the pending sign-in", notices.await(t)[0]) } } @@ -763,8 +793,8 @@ func TestReceiverLoginStatusInDirectMessageShowsTheCode(t *testing.T) { r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 42, Text: "/login status"})) - if len(notices.texts) != 1 || !strings.Contains(notices.texts[0], codexauthtest.DeviceCode) { - t.Errorf("notices = %v, want the pending code re-shown in a direct message", notices.texts) + if len(notices.await(t)) != 1 || !strings.Contains(notices.await(t)[0], codexauthtest.DeviceCode) { + t.Errorf("notices = %v, want the pending code re-shown in a direct message", notices.seen()) } } @@ -826,12 +856,12 @@ func TestPrivacyFollowsTheDirectMessageInvariant(t *testing.T) { FromID: tt.fromID, PeerID: tt.peerID, Text: "/login status", })) - if len(notices.texts) != 1 { - t.Fatalf("notices = %v, want exactly one", notices.texts) + if len(notices.await(t)) != 1 { + t.Fatalf("notices = %v, want exactly one", notices.seen()) } - got := strings.Contains(notices.texts[0], codexauthtest.DeviceCode) + got := strings.Contains(notices.await(t)[0], codexauthtest.DeviceCode) if got != tt.wantsCode { - t.Errorf("code shown = %v, want %v (reply: %q)", got, tt.wantsCode, notices.texts[0]) + t.Errorf("code shown = %v, want %v (reply: %q)", got, tt.wantsCode, notices.await(t)[0]) } }) } @@ -846,3 +876,60 @@ func TestHelpLineComesFromTheCanonicalSet(t *testing.T) { t.Error("the VK help does not render the canonical /login line") } } + +// TestSilentMessagesGetNoUnauthorizedNotice: a sticker or an empty message is +// dropped silently when the deployment is healthy, so an unauthorized one must +// not answer it either — that spends rate limit telling the user about work they +// never asked for. +func TestSilentMessagesGetNoUnauthorizedNotice(t *testing.T) { + svc := &fakeService{} + notices := &fakeNotice{} + r := unauthorizedCodexReceiver(t, svc, notices) + + // No text, no voice, no attachment the receiver would save. + r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 42, Text: " "})) + + if got := notices.seen(); len(got) != 0 { + t.Errorf("an empty message drew %v, want silence", got) + } + if len(svc.handleCalls) != 0 { + t.Errorf("an empty message started %d runs, want 0", len(svc.handleCalls)) + } +} + +// TestRealMessagesStillGetTheUnauthorizedNotice is the other half: a message that +// WOULD have run must still be answered. +func TestRealMessagesStillGetTheUnauthorizedNotice(t *testing.T) { + svc := &fakeService{} + notices := &fakeNotice{} + r := unauthorizedCodexReceiver(t, svc, notices) + + r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 42, Text: "build it"})) + + if got := notices.await(t); !strings.Contains(got[0], "/login") { + t.Errorf("notice = %q, want it to point at /login", got[0]) + } +} + +// TestLoginDoesNotBlockThePollLoop: this receiver processes updates serially, and +// /login can block for seconds (Cancel waits out the login goroutine; each reply +// is a bounded transport call). Handling it inline would stall every chat, so it +// runs detached — dispatch must return without waiting for the reply. +func TestLoginDoesNotBlockThePollLoop(t *testing.T) { + svc := &fakeService{} + notices := &fakeNotice{} + r := newTestReceiverWithAuth(svc, notices, logintest.PendingLogin(t)) + + done := make(chan struct{}) + go func() { + defer close(done) + r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 42, Text: "/login cancel"})) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("dispatch blocked on /login; the poll loop would stall with it") + } + notices.await(t) +} diff --git a/cmd/flock-telegram/codex_login_test.go b/cmd/flock-telegram/codex_login_test.go index 4215d02..07ec598 100644 --- a/cmd/flock-telegram/codex_login_test.go +++ b/cmd/flock-telegram/codex_login_test.go @@ -33,6 +33,9 @@ func unauthorizedCodexAuth(t *testing.T) (*codexauth.Manager, string) { AuthMode: codexauth.AuthSubscription, RequireAuth: true, Home: home, + // A deliberately absent binary, so no case can start the REAL codex and sit + // polling for the full DeviceCodeTTL. + Bin: filepath.Join(home, "no-such-codex"), }), home } diff --git a/cmd/flock-telegram/main.go b/cmd/flock-telegram/main.go index ca082a8..fa91052 100644 --- a/cmd/flock-telegram/main.go +++ b/cmd/flock-telegram/main.go @@ -554,7 +554,13 @@ func handleMessage(ctx context.Context, deps messageDeps, b *bot.Bot, msg *model // nothing, while transcription and downloads do. /login is a reserved command // routed elsewhere, so it stays reachable while this gate is closed. core/chat // gates the run itself; this is the early, user-facing half. - if notice, blocked := deps.auth.BlockedNotice(); blocked { + // + // Only for a message that WOULD have started a run: a sticker, an empty message + // or an unsupported attachment is dropped silently further down, and answering + // those with "not authorized" spends rate limit to tell the user about work they + // never asked for. + hasWork := strings.TrimSpace(cleaned) != "" || isVoice || isDocument || isPhoto + if notice, blocked := deps.auth.BlockedNotice(); blocked && hasWork { slog.Debug("codex unauthorized — blocking run", "chat_id", msg.Chat.ID) sendCommandReply(ctx, b, msg.Chat.ID, notice) return diff --git a/core/codexauth/codexauth.go b/core/codexauth/codexauth.go index 6d09294..84f9d4f 100644 --- a/core/codexauth/codexauth.go +++ b/core/codexauth/codexauth.go @@ -301,9 +301,14 @@ const LoginPrivateOnlyText = "Send /login in a direct message with me, not here. "The reply carries a one-time code that authorizes an account for the whole bot, " + "and everyone in this chat would see it." -// pendingElsewhereText reports a pending sign-in without reprinting its code. -const pendingElsewhereText = "A Codex sign-in is in progress. Its link and one-time code went to the " + - "direct message that started it — send /login there to see them again." +// pendingElsewhereText reports a pending sign-in without reprinting its code. It +// is deliberately neutral about WHOSE direct message: the code is re-shown to any +// private destination that asks, not only the one that started the login, and +// telling the user otherwise would misdescribe the very thing this message is +// about. (That any allow-listed user can finish a started sign-in with their own +// account is a documented property — one Codex identity serves the deployment.) +const pendingElsewhereText = "A Codex sign-in is in progress. Its link and one-time code go only to direct " + + "messages — send /login in a direct message with me to see them again." // LoginUsage is the /login help. const LoginUsage = "Usage:\n" + From c454c945216bd9b0b4ce0791e005423fd8ff66bb Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 15:12:57 +0300 Subject: [PATCH 12/21] fix(codex): one work decision, one bounded sender, and a test that means it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tenth review round on #66. - The immediate /login reply went out on the raw poll-loop context while the notice path deliberately detached and bounded its own. Since the handler now runs on its own goroutine, that context can already be cancelled: the replies to /login status, /login cancel and the direct-message refusal would be dropped silently at shutdown, and none carried a deadline. Both paths now share one bounded, detached sender. - The VK gate and the dispatch chain were two independent condition lists that happened to agree. They agree only until the next attachment type is added below, after which an unauthorized deploy would drop that message silently instead of pointing at /login. There is now one workKind decision, made once and used by both. - await returned on the FIRST notice, so every "exactly one" assertion actually meant "at least one" — which mattered most for the privacy regressions, since they look for the code in notice [0] and would have stayed green if the code were also broadcast to the conversation as a second message. It is now awaitOne, which waits, asserts the count, and re-checks after a settle. - /login cancel in a group borrowed the start refusal, whose reason is that the reply carries a one-time code. A cancel reply carries none, so the explanation was simply untrue; it has its own text about control of the sign-in. Open question for the deploy owner, raised by the reviewer and worth an explicit answer: any allow-listed user can finish a sign-in someone else started and thus become the deployment's single Codex identity. That is inherent to one Codex account per deployment and is now documented in both env files, but it is a policy call, not a code one. golangci-lint: 0 issues. Build, vet, full suite and -race (-count=2 on the packages this touches) green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/vk/receiver.go | 108 ++++++++++++++++++++----------- adapters/vk/receiver_test.go | 98 +++++++++++++++++++--------- core/codexauth/codexauth.go | 12 +++- core/codexauth/codexauth_test.go | 9 ++- 4 files changed, 153 insertions(+), 74 deletions(-) diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index dace5b9..05a999f 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -350,38 +350,40 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { // or an unsupported attachment is dropped silently further down, and answering // those with "not authorized" spends rate limit to tell the user about work they // never asked for. - if notice, blocked := r.auth.BlockedNotice(); blocked && r.hasWork(cleaned, msg) { + text := strings.TrimSpace(cleaned) + // Decided ONCE, and used by both the gate and the dispatch below. Two + // independent condition lists would agree only until the next attachment type is + // added: the new branch would run, but the gate would not know about it, so an + // unauthorized deploy would drop that message silently instead of pointing at + // /login. + kind := r.workKind(text, msg) + + if notice, blocked := r.auth.BlockedNotice(); blocked && kind != workNone { r.logger.Debug("vk: codex unauthorized — blocking run", "peer_id", peerID) r.notify(ctx, peerID, notice) return } - text := strings.TrimSpace(cleaned) - - // Voice: transcribe the first audio_message attachment (gate already passed, so - // we only pay for transcription on an accepted message). - if att := firstAudioMessage(msg.Attachments); att != nil && r.voice != nil && text == "" { - r.handleVoice(ctx, msg, att) + switch kind { + case workNone: return - } - - // Inbound document / photo: save to the per-chat uploads dir (outside every git - // tree) and submit a run referencing the saved path (a photo also attaches a - // vision content block). - if r.uploads != nil { - if doc := firstDoc(msg.Attachments); doc != nil { - r.handleDocument(ctx, msg, doc, text) - return - } - if ph := firstPhoto(msg.Attachments); ph != nil { - r.handlePhoto(ctx, msg, ph, text) - return - } - } - - if text == "" { + case workVoice: + // Transcribe the first audio_message attachment (gate already passed, so we + // only pay for transcription on an accepted message). + r.handleVoice(ctx, msg, firstAudioMessage(msg.Attachments)) + return + case workDoc: + // Save to the per-chat uploads dir (outside every git tree) and submit a run + // referencing the saved path. + r.handleDocument(ctx, msg, firstDoc(msg.Attachments), text) return + case workPhoto: + // As workDoc, and the run also carries a vision content block. + r.handlePhoto(ctx, msg, firstPhoto(msg.Attachments), text) + return + case workText: } + // Fold any quoted/replied-to/forwarded original into the prompt so the run sees // the context the user is referring to, not just their new text. QuotedPrompt is // a strict no-op when there is no quote, so a normal message stays unchanged. @@ -390,17 +392,39 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { r.svc.Handle(ctx, chatIDStr(peerID), msg.FromID, msgIDStr(msg.ConversationMessageID), text) } -// hasWork reports whether this message carries something the receiver would -// actually submit: text (after the mention gate stripped the address), a voice -// note to transcribe, or an attachment to save. It mirrors the branches below. -func (r *Receiver) hasWork(cleaned string, msg messageObject) bool { - if strings.TrimSpace(cleaned) != "" { - return true +// workKind names what an accepted message would submit. workNone means the +// receiver would drop it, which is also what decides whether the unauthorized +// notice is worth sending. +type workKind int + +const ( + workNone workKind = iota + workText + workVoice + workDoc + workPhoto +) + +// workKind classifies an accepted message. text is the mention-stripped, trimmed +// body. The precedence is the dispatch order: a voice note counts only while +// there is no text to run instead, and attachments only when an uploader is +// configured. +func (r *Receiver) workKind(text string, msg messageObject) workKind { + if text == "" && r.voice != nil && firstAudioMessage(msg.Attachments) != nil { + return workVoice } - if r.voice != nil && firstAudioMessage(msg.Attachments) != nil { - return true + if r.uploads != nil { + if firstDoc(msg.Attachments) != nil { + return workDoc + } + if firstPhoto(msg.Attachments) != nil { + return workPhoto + } + } + if text != "" { + return workText } - return r.uploads != nil && (firstDoc(msg.Attachments) != nil || firstPhoto(msg.Attachments) != nil) + return workNone } // dispatchReserved acts on a reserved command (caller already confirmed the name @@ -456,6 +480,16 @@ func (r *Receiver) dispatchReserved(ctx context.Context, name string, msg messag // long gone by the time a user finishes in a browser. func (r *Receiver) dispatchLogin(ctx context.Context, msg messageObject) { peerID := msg.PeerID + // One bounded, detached sender for BOTH paths. This handler runs on its own + // goroutine, so the poll loop's context may be cancelled while it is still + // working — on the raw context the replies to /login status, /login cancel and + // the direct-message refusal would be dropped silently at shutdown, and none of + // them would carry a deadline of its own. + notify := func(text string) { + sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexauth.NotifyTimeout) + defer cancel() + r.notify(sendCtx, peerID, text) + } sub := codexauth.Subscriber{ ID: chatIDStr(peerID), // VK states the 1:1 invariant directly in the update: in a direct message the @@ -463,15 +497,11 @@ func (r *Receiver) dispatchLogin(ctx context.Context, msg messageObject) { // every non-conversation peer private, community peers included — too loose a // test for a flag that decides who may take over the bot's account. Private: peerID == msg.FromID, - Notify: func(text string) { - sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexauth.NotifyTimeout) - defer cancel() - r.notify(sendCtx, peerID, text) - }, + Notify: notify, } // An empty reply means Dispatch already delivered it through sub, in order. if reply := r.auth.Dispatch(ctx, commandArgs(msg.Text), sub); reply != "" { - r.notify(ctx, peerID, reply) + notify(reply) } } diff --git a/adapters/vk/receiver_test.go b/adapters/vk/receiver_test.go index f73878e..e977337 100644 --- a/adapters/vk/receiver_test.go +++ b/adapters/vk/receiver_test.go @@ -127,18 +127,29 @@ func (n *fakeNotice) seen() []string { return append([]string(nil), n.texts...) } -// await waits for at least one notice to arrive and returns everything seen. -func (n *fakeNotice) await(t *testing.T) []string { +// awaitOne waits for exactly ONE notice and fails if a second arrives. +// +// Returning on the FIRST notice would have made every "exactly one" assertion +// mean "at least one": a second message almost never lands before the snapshot is +// read. That matters most for the privacy regressions here — they look for the +// device code in notice [0], so an implementation that also broadcast it to the +// conversation as a second message would keep them green. +func (n *fakeNotice) awaitOne(t *testing.T) []string { t.Helper() deadline := time.Now().Add(10 * time.Second) - for time.Now().Before(deadline) { - if got := n.seen(); len(got) > 0 { - return got - } + for time.Now().Before(deadline) && len(n.seen()) == 0 { time.Sleep(2 * time.Millisecond) } - t.Fatal("no notice arrived") - return nil + got := n.seen() + if len(got) != 1 { + t.Fatalf("got %d notices, want 1: %v", len(got), got) + } + // Give a stray second a chance to show up, so "exactly one" means it. + time.Sleep(50 * time.Millisecond) + if extra := n.seen(); len(extra) != 1 { + t.Fatalf("got %d notices, want 1: %v", len(extra), extra) + } + return got } // newTestReceiver builds a Receiver wired to the fakes, allowing user 42, group @@ -714,8 +725,8 @@ func TestReceiverLoginCommandStaysReachableWhileUnauthorized(t *testing.T) { if len(svc.handleCalls) != 0 { t.Errorf("/login should not start a run, got %d Handle calls", len(svc.handleCalls)) } - if len(notices.await(t)) != 1 || !strings.Contains(notices.await(t)[0], "NOT authorized") { - t.Errorf("notices = %v, want the login status reply", notices.seen()) + if got := notices.awaitOne(t); !strings.Contains(got[0], "NOT authorized") { + t.Errorf("notices = %v, want the login status reply", got) } } @@ -728,8 +739,8 @@ func TestReceiverLoginWithoutManagerIsInert(t *testing.T) { r := newTestReceiver(svc, notices, false, nil) r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 42, Text: "/login"})) - if len(notices.await(t)) != 1 || notices.await(t)[0] != codexauth.NoLoginNeededText { - t.Errorf("notices = %v, want the no-login-needed reply", notices.seen()) + if got := notices.awaitOne(t); got[0] != codexauth.NoLoginNeededText { + t.Errorf("notices = %v, want the no-login-needed reply", got) } r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 200, Text: "build it"})) @@ -750,10 +761,11 @@ func TestReceiverLoginRefusedInConversation(t *testing.T) { // 2000000000+ is VK's conversation (chat) peer range. r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 2000000001, Text: "/login"})) - if len(notices.await(t)) != 1 || !strings.Contains(notices.await(t)[0], "direct message") { - t.Fatalf("notices = %v, want the refusal pointing at a direct message", notices.seen()) + got := notices.awaitOne(t) + if !strings.Contains(got[0], "direct message") { + t.Fatalf("notices = %v, want the refusal pointing at a direct message", got) } - if strings.Contains(notices.await(t)[0], "http") { + if strings.Contains(got[0], "http") { t.Error("the refusal leaked a link into the conversation") } } @@ -772,14 +784,12 @@ func TestReceiverLoginStatusInConversationHidesAPendingCode(t *testing.T) { FromID: 42, PeerID: 2000000001, Text: "/login status", })) - if len(notices.await(t)) != 1 { - t.Fatalf("notices = %v, want exactly one", notices.seen()) + got := notices.awaitOne(t) + if strings.Contains(got[0], codexauthtest.DeviceCode) || strings.Contains(got[0], "http") { + t.Errorf("the pending code or link reached a conversation: %q", got[0]) } - if strings.Contains(notices.await(t)[0], codexauthtest.DeviceCode) || strings.Contains(notices.await(t)[0], "http") { - t.Errorf("the pending code or link reached a conversation: %q", notices.await(t)[0]) - } - if !strings.Contains(notices.await(t)[0], "in progress") { - t.Errorf("status = %q, want it to still report the pending sign-in", notices.await(t)[0]) + if !strings.Contains(got[0], "in progress") { + t.Errorf("status = %q, want it to still report the pending sign-in", got[0]) } } @@ -793,8 +803,8 @@ func TestReceiverLoginStatusInDirectMessageShowsTheCode(t *testing.T) { r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 42, Text: "/login status"})) - if len(notices.await(t)) != 1 || !strings.Contains(notices.await(t)[0], codexauthtest.DeviceCode) { - t.Errorf("notices = %v, want the pending code re-shown in a direct message", notices.seen()) + if got := notices.awaitOne(t); !strings.Contains(got[0], codexauthtest.DeviceCode) { + t.Errorf("notices = %v, want the pending code re-shown in a direct message", got) } } @@ -856,12 +866,10 @@ func TestPrivacyFollowsTheDirectMessageInvariant(t *testing.T) { FromID: tt.fromID, PeerID: tt.peerID, Text: "/login status", })) - if len(notices.await(t)) != 1 { - t.Fatalf("notices = %v, want exactly one", notices.seen()) - } - got := strings.Contains(notices.await(t)[0], codexauthtest.DeviceCode) + shown := notices.awaitOne(t) + got := strings.Contains(shown[0], codexauthtest.DeviceCode) if got != tt.wantsCode { - t.Errorf("code shown = %v, want %v (reply: %q)", got, tt.wantsCode, notices.await(t)[0]) + t.Errorf("code shown = %v, want %v (reply: %q)", got, tt.wantsCode, notices.awaitOne(t)[0]) } }) } @@ -906,7 +914,7 @@ func TestRealMessagesStillGetTheUnauthorizedNotice(t *testing.T) { r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 42, Text: "build it"})) - if got := notices.await(t); !strings.Contains(got[0], "/login") { + if got := notices.awaitOne(t); !strings.Contains(got[0], "/login") { t.Errorf("notice = %q, want it to point at /login", got[0]) } } @@ -931,5 +939,33 @@ func TestLoginDoesNotBlockThePollLoop(t *testing.T) { case <-time.After(5 * time.Second): t.Fatal("dispatch blocked on /login; the poll loop would stall with it") } - notices.await(t) + notices.awaitOne(t) +} + +// TestWorkKindDrivesBothGateAndDispatch pins the single decision: whatever the +// receiver would submit is exactly what the unauthorized gate answers about. Two +// independent condition lists would agree only until the next attachment type is +// added to the dispatch switch. +func TestWorkKindDrivesBothGateAndDispatch(t *testing.T) { + r := newTestReceiver(&fakeService{}, &fakeNotice{}, false, nil) + r.uploads = nil + r.voice = nil + + tests := []struct { + name string + text string + msg messageObject + want workKind + }{ + {"plain text", "build it", messageObject{}, workText}, + {"nothing at all", "", messageObject{}, workNone}, + {"attachment with no uploader", "", messageObject{Attachments: []attachment{{Type: "doc"}}}, workNone}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := r.workKind(tt.text, tt.msg); got != tt.want { + t.Errorf("workKind = %v, want %v", got, tt.want) + } + }) + } } diff --git a/core/codexauth/codexauth.go b/core/codexauth/codexauth.go index 84f9d4f..ca2f465 100644 --- a/core/codexauth/codexauth.go +++ b/core/codexauth/codexauth.go @@ -296,11 +296,19 @@ func (m *Manager) notApplicableText() string { ", which authenticates from its configured credentials." } -// LoginPrivateOnlyText refuses to start a sign-in outside a 1:1 chat. +// LoginPrivateOnlyText refuses to START a sign-in outside a 1:1 chat. const LoginPrivateOnlyText = "Send /login in a direct message with me, not here.\n\n" + "The reply carries a one-time code that authorizes an account for the whole bot, " + "and everyone in this chat would see it." +// cancelPrivateOnlyText refuses to abort a sign-in from outside a 1:1 chat. It is +// separate from LoginPrivateOnlyText because the reason differs: a cancel reply +// carries no code, so explaining the refusal in terms of one would be simply +// untrue. What is at stake is control — the sign-in belongs to the direct message +// running it, and its confirmation would reveal that one is under way. +const cancelPrivateOnlyText = "Send /login cancel in a direct message with me, not here.\n\n" + + "A sign-in is controlled from the direct message it runs in." + // pendingElsewhereText reports a pending sign-in without reprinting its code. It // is deliberately neutral about WHOSE direct message: the code is re-shown to any // private destination that asks, not only the one that started the login, and @@ -387,7 +395,7 @@ func (m *Manager) Dispatch(base context.Context, args string, sub Subscriber) st // direct message, and the confirmation would itself reveal that one is // underway. Control of the login stays on the 1:1 channel that owns it. if !sub.Private { - return LoginPrivateOnlyText + return cancelPrivateOnlyText } if m.Cancel() { return "Cancelled the pending Codex login." diff --git a/core/codexauth/codexauth_test.go b/core/codexauth/codexauth_test.go index f057632..882933b 100644 --- a/core/codexauth/codexauth_test.go +++ b/core/codexauth/codexauth_test.go @@ -735,8 +735,13 @@ func TestCancelIsPrivateOnly(t *testing.T) { c.awaitCode(t) got := m.Dispatch(context.Background(), "cancel", Subscriber{ID: "group"}) - if got != LoginPrivateOnlyText { - t.Errorf("group cancel = %q, want the direct-message refusal", got) + if got != cancelPrivateOnlyText { + t.Errorf("group cancel = %q, want the cancel-specific refusal", got) + } + // The refusal must explain ITSELF: a cancel reply carries no code, so borrowing + // the start refusal's reasoning would simply be untrue. + if strings.Contains(got, "one-time code") { + t.Errorf("cancel refusal = %q, want it not to claim a code is at stake", got) } if !strings.Contains(m.StatusText(), "in progress") { t.Error("a group aborted a sign-in owned by a direct message") From 1011006d7b9589e908a5d3b9b713f8bbf331aee9 Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 15:24:08 +0300 Subject: [PATCH 13/21] fix(codex): carry the attachment with the work decision, not just its kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleventh review round on #66. MAJOR — my own workKind refactor last round unified the classification but not the extraction: the dispatch switch called firstAudioMessage/firstDoc/firstPhoto a SECOND time and passed the result straight into handlers that dereference it unconditionally. It cannot fire today (same message, same answer), but the absence of a nil-panic in the poll loop rested on two functions agreeing to keep picking the same attachment, which is exactly the coupling the refactor claimed to remove — and it walked the attachments twice per message. classify now returns the attachment alongside the kind, so the search happens once and the handler is given what was actually found. Also from the review: - The switch had no default, and the deliberately-empty workText case falls through to the text run. A kind added to classify but forgotten here would have been submitted as whatever was in text, possibly nothing — the old code was protected by a final `if text == ""` that the refactor removed. It now logs and drops. - The first-sign-in prose was written twice, in .env.example and env.j2, already drifting between copies — against the principle this PR applies in code, where HelpBody and LoginHelpLine were moved to core/chat precisely because two byte-identical copies drift in silence. Prose has no test to catch it at all. The full description now lives in docs/codex-integration-plan.md, including who ends up owning the Codex account, and both env files carry three lines and a pointer. golangci-lint: 0 issues. Build, vet, full suite and -race (-count=2 on VK) green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/telegram/.env.example | 18 ++---- .../roles/claude_tg_bot/templates/env.j2 | 14 ++--- adapters/vk/receiver.go | 62 ++++++++++++------- adapters/vk/receiver_test.go | 38 +++++++++--- docs/codex-integration-plan.md | 17 +++++ 5 files changed, 99 insertions(+), 50 deletions(-) diff --git a/adapters/telegram/.env.example b/adapters/telegram/.env.example index cc88d33..dbf515d 100644 --- a/adapters/telegram/.env.example +++ b/adapters/telegram/.env.example @@ -21,18 +21,12 @@ AI_BACKEND=claude # claude | codex | openai-compatible # Keep CODEX_API_KEY empty in subscription mode: startup fails if it is set, so a # subscription deploy cannot accidentally switch to usage-based API billing. # -# First-time sign-in needs no shell on the host: the bot STARTS unauthorized and -# an allow-listed user sends /login in a DIRECT MESSAGE (the reply carries a -# one-time code, so starting a sign-in is refused in group chats). The bot -# replies with a verification link and a one-time code (this is `codex login --device-auth`; -# plain `codex login` would open a loopback server on localhost:1455 that nothing -# outside the container can reach, which is why it appears to hang). Open the -# link, enter the code, and the bot confirms. With CODEX_REQUIRE_AUTH=true (the -# default below) runs are blocked until then; with it false the bot runs -# immediately and an unfinished sign-in surfaces as a Codex CLI failure instead. -# Any allow-listed user can finish a sign-in already under way: the code is -# re-shown in any direct message, and whoever completes it becomes the -# deployment's single Codex identity. +# First sign-in needs no shell on the host: the bot starts unauthorized and an +# allow-listed user sends /login IN A DIRECT MESSAGE (the reply carries a one-time +# code, so starting a sign-in is refused in group chats). Runs are blocked until +# it completes, unless CODEX_REQUIRE_AUTH=false. +# Full flow, and who ends up owning the Codex account: +# docs/codex-integration-plan.md#subscription-setup CODEX_AUTH_MODE=subscription # subscription | billing CODEX_BIN=codex CODEX_HOME=/home/claude/.codex diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 b/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 index abb9e9e..923a568 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 +++ b/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 @@ -30,16 +30,10 @@ CLAUDE_MAX_COST_PER_REQUEST={{ claude_max_cost_per_request }} # === CODEX AUTH === # subscription: use persisted {{ codex_home }}/auth.json or CODEX_ACCESS_TOKEN. # With neither, the bot starts unauthorized and an allow-listed user completes -# the one-time sign-in from the chat with /login IN A DIRECT MESSAGE — the reply -# carries a one-time code that authorizes an account for the whole bot, so -# starting a sign-in is refused in group chats. The bot posts a link + a -# one-time code. No shell on the host is needed. -# Any allow-listed user can finish a sign-in already under way — the code is -# re-shown in any direct message, and whoever completes it becomes the -# deployment's single Codex identity. Runs are blocked until the -# sign-in lands only when CODEX_REQUIRE_AUTH is true (set below); when it is -# false the bot runs immediately and an unfinished sign-in surfaces as a Codex -# CLI failure instead. +# the one-time sign-in from the chat with /login IN A DIRECT MESSAGE. Runs are +# blocked until it lands, unless CODEX_REQUIRE_AUTH is false. +# Full flow, and who ends up owning the Codex account: +# docs/codex-integration-plan.md#subscription-setup # billing: requires CODEX_API_KEY and CODEX_BILLING_ACK=true. CODEX_BIN={{ codex_bin }} CODEX_MODEL={{ codex_model }} diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index 05a999f..a9d6cb9 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -356,32 +356,38 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { // added: the new branch would run, but the gate would not know about it, so an // unauthorized deploy would drop that message silently instead of pointing at // /login. - kind := r.workKind(text, msg) + w := r.classify(text, msg) - if notice, blocked := r.auth.BlockedNotice(); blocked && kind != workNone { + if notice, blocked := r.auth.BlockedNotice(); blocked && w.kind != workNone { r.logger.Debug("vk: codex unauthorized — blocking run", "peer_id", peerID) r.notify(ctx, peerID, notice) return } - switch kind { + switch w.kind { case workNone: return case workVoice: - // Transcribe the first audio_message attachment (gate already passed, so we - // only pay for transcription on an accepted message). - r.handleVoice(ctx, msg, firstAudioMessage(msg.Attachments)) + // Transcribe the audio_message attachment (gate already passed, so we only + // pay for transcription on an accepted message). + r.handleVoice(ctx, msg, w.voice) return case workDoc: // Save to the per-chat uploads dir (outside every git tree) and submit a run // referencing the saved path. - r.handleDocument(ctx, msg, firstDoc(msg.Attachments), text) + r.handleDocument(ctx, msg, w.doc, text) return case workPhoto: // As workDoc, and the run also carries a vision content block. - r.handlePhoto(ctx, msg, firstPhoto(msg.Attachments), text) + r.handlePhoto(ctx, msg, w.photo, text) return case workText: + // Falls through to the text run below. + default: + // A kind added to classify but not handled here would otherwise fall through + // to the text run and submit whatever is in text — possibly nothing. + r.logger.Warn("vk: unhandled work kind; dropping message", "kind", w.kind, "peer_id", peerID) + return } // Fold any quoted/replied-to/forwarded original into the prompt so the run sees @@ -405,26 +411,40 @@ const ( workPhoto ) -// workKind classifies an accepted message. text is the mention-stripped, trimmed -// body. The precedence is the dispatch order: a voice note counts only while -// there is no text to run instead, and attachments only when an uploader is -// configured. -func (r *Receiver) workKind(text string, msg messageObject) workKind { - if text == "" && r.voice != nil && firstAudioMessage(msg.Attachments) != nil { - return workVoice +// work is what an accepted message would submit: its kind, and the attachment the +// matching handler needs. Carrying the attachment is the point — the handlers +// dereference it unconditionally, so finding it a second time in the dispatch +// switch would put a nil-panic in the poll loop behind nothing but an agreement +// between two functions to keep picking the same one. +type work struct { + kind workKind + voice *audioMsgAttach + doc *docAttachment + photo *photoAttachment +} + +// classify decides what an accepted message would submit, walking the +// attachments ONCE. text is the mention-stripped, trimmed body. The precedence is +// the dispatch order: a voice note counts only while there is no text to run +// instead, and attachments only when an uploader is configured. +func (r *Receiver) classify(text string, msg messageObject) work { + if text == "" && r.voice != nil { + if att := firstAudioMessage(msg.Attachments); att != nil { + return work{kind: workVoice, voice: att} + } } if r.uploads != nil { - if firstDoc(msg.Attachments) != nil { - return workDoc + if doc := firstDoc(msg.Attachments); doc != nil { + return work{kind: workDoc, doc: doc} } - if firstPhoto(msg.Attachments) != nil { - return workPhoto + if ph := firstPhoto(msg.Attachments); ph != nil { + return work{kind: workPhoto, photo: ph} } } if text != "" { - return workText + return work{kind: workText} } - return workNone + return work{kind: workNone} } // dispatchReserved acts on a reserved command (caller already confirmed the name diff --git a/adapters/vk/receiver_test.go b/adapters/vk/receiver_test.go index e977337..2f8443c 100644 --- a/adapters/vk/receiver_test.go +++ b/adapters/vk/receiver_test.go @@ -942,11 +942,11 @@ func TestLoginDoesNotBlockThePollLoop(t *testing.T) { notices.awaitOne(t) } -// TestWorkKindDrivesBothGateAndDispatch pins the single decision: whatever the -// receiver would submit is exactly what the unauthorized gate answers about. Two -// independent condition lists would agree only until the next attachment type is -// added to the dispatch switch. -func TestWorkKindDrivesBothGateAndDispatch(t *testing.T) { +// TestClassifyDrivesBothGateAndDispatch pins the single decision: whatever the +// receiver would submit is exactly what the unauthorized gate answers about, and +// the attachment travels WITH that decision. Two independent condition lists +// would agree only until the next attachment type is added to the dispatch switch. +func TestClassifyDrivesBothGateAndDispatch(t *testing.T) { r := newTestReceiver(&fakeService{}, &fakeNotice{}, false, nil) r.uploads = nil r.voice = nil @@ -963,9 +963,33 @@ func TestWorkKindDrivesBothGateAndDispatch(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := r.workKind(tt.text, tt.msg); got != tt.want { - t.Errorf("workKind = %v, want %v", got, tt.want) + if got := r.classify(tt.text, tt.msg); got.kind != tt.want { + t.Errorf("classify kind = %v, want %v", got.kind, tt.want) } }) } } + +// stubUploader satisfies the uploader seam; classify only checks it is present. +type stubUploader struct{} + +func (stubUploader) Save(_ context.Context, _ int64, _, _ string) (string, error) { return "", nil } +func (stubUploader) MaxBytes() int64 { return 1 << 20 } + +// TestClassifyCarriesTheAttachment: every handler dereferences its argument +// unconditionally, so the decision must hand over the attachment it found rather +// than leave the dispatch to look again. +func TestClassifyCarriesTheAttachment(t *testing.T) { + r := newTestReceiver(&fakeService{}, &fakeNotice{}, false, nil) + r.uploads = stubUploader{} + + got := r.classify("", messageObject{Attachments: []attachment{{ + Type: "doc", Doc: &docAttachment{URL: "https://vk.example/doc", Title: "notes.txt"}, + }}}) + if got.kind != workDoc { + t.Fatalf("kind = %v, want workDoc", got.kind) + } + if got.doc == nil { + t.Fatal("classify returned workDoc with no document; the handler would panic") + } +} diff --git a/docs/codex-integration-plan.md b/docs/codex-integration-plan.md index 3aaebc9..a1c7201 100644 --- a/docs/codex-integration-plan.md +++ b/docs/codex-integration-plan.md @@ -588,6 +588,23 @@ Acceptance: `/login status` reports the auth state, `/login cancel` aborts a pending sign-in, and `/login force` re-authenticates an already-authorized deployment. +Two properties an operator should know before running it, because the `.env` +files point here rather than repeating them: + +- **Starting a sign-in only works in a direct message.** The reply carries a + one-time code that authorizes an account for the entire bot, so in a group + every member — allow-listed or not — could read it, and whoever acts on it + first binds their account. `/login status` works anywhere but withholds the + code; `/login cancel` is direct-message only too, since it controls a sign-in + someone else may be completing. +- **Any allow-listed user can finish a sign-in someone else started.** The code is + re-shown in any direct message, and the account that completes it becomes the + deployment's single Codex identity — one Codex account serves the whole + deployment. Restrict `ALLOWED_USERS` accordingly. + +With `CODEX_REQUIRE_AUTH=false` nothing is blocked: the bot runs immediately and +an unfinished sign-in surfaces as a Codex CLI failure instead. + Why device code and not the shell recipe this plan originally carried (`docker exec -it codex login --device-auth`): that still works, but it needs shell access to the host, and the plain `codex login` an operator From 9095cff0d702ec452bab2da3d8f22fd4ec23c562 Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 15:37:49 +0300 Subject: [PATCH 14/21] fix(codex): name the work kind in logs, share the greeting and the test manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelfth review round on #66. All four small, all consistent with rules this PR had already set for itself. - The dispatch switch's default branch exists to report a kind nobody handled, and logged it as a bare ordinal — sending the operator to the source to decode their own log line. workKind now has String(). - "Hi! I'm the Flock assistant." was still byte-identical in both adapters, after the PR moved HelpBody and LoginHelpLine to core/chat for exactly that reason. It is now chat.WelcomeGreeting. - Every test that needed an unauthorized deployment built the Config by hand, and the only thing stopping it from running the REAL codex (and polling for the whole DeviceCodeTTL) was remembering to point Bin at a path that does not exist — repeated in three places, with a comment admitting the risk. logintest.Unauthorized now makes that the default, so there is nothing left to forget. It sits in logintest rather than codexauthtest because it returns a codexauth type and codexauthtest must not import codexauth. - BlockedNotice() was evaluated before the "is there any work" check, so every sticker and empty message cost an os.Stat of the credential — and contradicted the comment right above it. Reordered. golangci-lint: 0 issues. Build, vet, full suite and -race green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/telegram/commands.go | 2 +- adapters/vk/commands.go | 2 +- adapters/vk/receiver.go | 30 +++++++++++++++++--- adapters/vk/receiver_test.go | 38 ++++++++++++++++++-------- cmd/flock-telegram/codex_login_test.go | 20 ++------------ core/chat/reserved.go | 5 ++++ core/chat/reserved_test.go | 12 ++++++++ core/codexauth/logintest/logintest.go | 24 ++++++++++++++++ 8 files changed, 97 insertions(+), 36 deletions(-) diff --git a/adapters/telegram/commands.go b/adapters/telegram/commands.go index f3c9b21..2904ca4 100644 --- a/adapters/telegram/commands.go +++ b/adapters/telegram/commands.go @@ -99,5 +99,5 @@ func HelpText(login chat.LoginVisibility) string { return helpTitle + chat.HelpB // it. Like HelpText it never reaches the Runner — the duck greeting comes from // the model on a real message. func WelcomeText(login chat.LoginVisibility) string { - return "Hi! I'm the Flock assistant.\n\n" + HelpText(login) + return chat.WelcomeGreeting + HelpText(login) } diff --git a/adapters/vk/commands.go b/adapters/vk/commands.go index 3332ad4..2e67c9e 100644 --- a/adapters/vk/commands.go +++ b/adapters/vk/commands.go @@ -15,7 +15,7 @@ func HelpText(login chat.LoginVisibility) string { return helpTitle + chat.HelpB // welcomeText is the reply to /start: a short greeting + the usage help, // mirroring the Telegram adapter's WelcomeText. func welcomeText(login chat.LoginVisibility) string { - return "Hi! I'm the Flock assistant.\n\n" + HelpText(login) + return chat.WelcomeGreeting + HelpText(login) } // goalUsageText is the /goal usage reply, mirroring the Telegram adapter. diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index a9d6cb9..4e066dd 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -358,10 +358,12 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { // /login. w := r.classify(text, msg) - if notice, blocked := r.auth.BlockedNotice(); blocked && w.kind != workNone { - r.logger.Debug("vk: codex unauthorized — blocking run", "peer_id", peerID) - r.notify(ctx, peerID, notice) - return + if w.kind != workNone { + if notice, blocked := r.auth.BlockedNotice(); blocked { + r.logger.Debug("vk: codex unauthorized — blocking run", "peer_id", peerID) + r.notify(ctx, peerID, notice) + return + } } switch w.kind { @@ -411,6 +413,26 @@ const ( workPhoto ) +// String names the kind, so the diagnostic in the dispatch switch reads +// "kind=workPhoto" instead of a bare ordinal an operator would have to look up in +// this file — the branch exists precisely to report a kind nobody handled. +func (k workKind) String() string { + switch k { + case workNone: + return "workNone" + case workText: + return "workText" + case workVoice: + return "workVoice" + case workDoc: + return "workDoc" + case workPhoto: + return "workPhoto" + default: + return "workKind(" + strconv.Itoa(int(k)) + ")" + } +} + // work is what an accepted message would submit: its kind, and the attachment the // matching handler needs. Carrying the attachment is the point — the handlers // dereference it unconditionally, so finding it a second time in the dispatch diff --git a/adapters/vk/receiver_test.go b/adapters/vk/receiver_test.go index 2f8443c..e77bd97 100644 --- a/adapters/vk/receiver_test.go +++ b/adapters/vk/receiver_test.go @@ -681,18 +681,8 @@ func isContextErr(err error) bool { // own plumbing instead of reaching past it into the private field. func unauthorizedCodexReceiver(t *testing.T, svc Service, notices NoticeSender) *Receiver { t.Helper() - home := t.TempDir() - return newTestReceiverWithAuth(svc, notices, codexauth.NewManager(codexauth.Config{ - Backend: codexauth.BackendCodex, - AuthMode: codexauth.AuthSubscription, - RequireAuth: true, - Home: home, - // A deliberately absent binary. No case here reaches a sign-in start today, - // but that is a property of the current set: one future /login from a direct - // message would otherwise run the REAL codex on a developer's machine and sit - // polling for the full DeviceCodeTTL. - Bin: filepath.Join(home, "no-such-codex"), - })) + auth, _ := logintest.Unauthorized(t) + return newTestReceiverWithAuth(svc, notices, auth) } // TestReceiverBlocksRunsWhileCodexUnauthorized: a Codex deploy with no completed @@ -993,3 +983,27 @@ func TestClassifyCarriesTheAttachment(t *testing.T) { t.Fatal("classify returned workDoc with no document; the handler would panic") } } + +// TestWorkKindString: the dispatch switch's default branch exists to report a +// kind nobody handled, so it has to name it — a bare ordinal sends the operator +// to the source to decode their own log line. +func TestWorkKindString(t *testing.T) { + tests := []struct { + kind workKind + want string + }{ + {workNone, "workNone"}, + {workText, "workText"}, + {workVoice, "workVoice"}, + {workDoc, "workDoc"}, + {workPhoto, "workPhoto"}, + {workKind(42), "workKind(42)"}, + } + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := tt.kind.String(); got != tt.want { + t.Errorf("String() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/cmd/flock-telegram/codex_login_test.go b/cmd/flock-telegram/codex_login_test.go index 07ec598..ddd28c8 100644 --- a/cmd/flock-telegram/codex_login_test.go +++ b/cmd/flock-telegram/codex_login_test.go @@ -27,16 +27,7 @@ import ( // returns that home so a test can "complete" the login by planting the file. func unauthorizedCodexAuth(t *testing.T) (*codexauth.Manager, string) { t.Helper() - home := t.TempDir() - return codexauth.NewManager(codexauth.Config{ - Backend: codexauth.BackendCodex, - AuthMode: codexauth.AuthSubscription, - RequireAuth: true, - Home: home, - // A deliberately absent binary, so no case can start the REAL codex and sit - // polling for the full DeviceCodeTTL. - Bin: filepath.Join(home, "no-such-codex"), - }), home + return logintest.Unauthorized(t) } // The allowed sender and the private chat every case in this file uses. @@ -186,14 +177,7 @@ func groupCommandUpdate(text string, cmdLen int) *models.Update { func TestLoginRefusedInGroupChat(t *testing.T) { // A deliberately unusable CODEX_BIN: if the refusal failed to short-circuit, // the manager would try to run it, which the status assertion below catches. - home := t.TempDir() - auth := codexauth.NewManager(codexauth.Config{ - Backend: codexauth.BackendCodex, - AuthMode: codexauth.AuthSubscription, - RequireAuth: true, - Home: home, - Bin: filepath.Join(home, "no-such-codex"), - }) + auth, _ := logintest.Unauthorized(t) got := groupCommandReplies(t, auth, "/login") if len(got) != 1 { diff --git a/core/chat/reserved.go b/core/chat/reserved.go index 167b494..4776af5 100644 --- a/core/chat/reserved.go +++ b/core/chat/reserved.go @@ -75,6 +75,11 @@ const helpCommands = "/help — show this message\n" + "/goal — arm a goal an independent evaluator re-checks after every run " + "(/goal off to disarm)\n" +// WelcomeGreeting opens the /start reply, ahead of the same help both adapters +// render. It lives here for the reason HelpBody does: two byte-identical copies +// share no link but a habit, and drift without anything failing. +const WelcomeGreeting = "Hi! I'm the Flock assistant.\n\n" + // helpTail closes the usage message. const helpTail = "\nSend any other message to run it through the assistant." diff --git a/core/chat/reserved_test.go b/core/chat/reserved_test.go index ab4a263..23ee434 100644 --- a/core/chat/reserved_test.go +++ b/core/chat/reserved_test.go @@ -132,3 +132,15 @@ func TestLoginHelpLineNamesTheDirectMessageRequirement(t *testing.T) { } } } + +// TestWelcomeGreetingHasOneHome: both adapters open /start with this line, so it +// lives beside the help body they also share. Two byte-identical copies share no +// link but a habit. +func TestWelcomeGreetingHasOneHome(t *testing.T) { + if chat.WelcomeGreeting == "" { + t.Fatal("WelcomeGreeting is empty") + } + if !strings.HasSuffix(chat.WelcomeGreeting, "\n\n") { + t.Errorf("WelcomeGreeting = %q, want it to end with a blank line before the help", chat.WelcomeGreeting) + } +} diff --git a/core/codexauth/logintest/logintest.go b/core/codexauth/logintest/logintest.go index dfad9d9..4e161ad 100644 --- a/core/codexauth/logintest/logintest.go +++ b/core/codexauth/logintest/logintest.go @@ -12,6 +12,7 @@ package logintest import ( "context" "os" + "path/filepath" "testing" "github.com/duckbugio/flock/core/codexauth" @@ -22,6 +23,29 @@ import ( // slow reader cannot block the manager's broadcast. const noticeBuffer = 8 +// Unauthorized returns a Manager for a deployment that has never been signed in, +// and the scratch CODEX_HOME it watches, so a test can complete the sign-in by +// planting auth.json there. +// +// Bin points at a path that does not exist ON PURPOSE. Every caller previously +// set that by hand, and forgetting it in one future case would run the REAL +// `codex login --device-auth` on a developer's machine and leave it polling for +// the whole DeviceCodeTTL. Making it the default removes the chance to forget. +// +// (Here rather than in codexauthtest because this returns a codexauth type, and +// codexauthtest must not import codexauth — see the package doc.) +func Unauthorized(t *testing.T) (*codexauth.Manager, string) { + t.Helper() + home := t.TempDir() + return codexauth.NewManager(codexauth.Config{ + Backend: codexauth.BackendCodex, + AuthMode: codexauth.AuthSubscription, + RequireAuth: true, + Home: home, + Bin: filepath.Join(home, "no-such-codex"), + }), home +} + // PendingLogin returns a Manager whose device login is under way and whose // one-time code has already been issued, with a scratch CODEX_HOME holding no // credential. The login is cancelled when the test ends. From 70b3934de0f1fb52f8a9381419a32c5d136c9f9f Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 18:37:41 +0300 Subject: [PATCH 15/21] fix(codex): pause the follow-up sweep instead of destroying what it takes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirteenth review round on #66. The first item is a data-loss bug I introduced two rounds ago. Gating InjectAuto closed the last hole in the run gate, but the follow-up sweeper reaches it through a take-then-fire store: followup.FileStore.Due REMOVES what it returns, and only then is InjectAuto called. On an unauthorized deployment every matured followup/.md was therefore deleted and never replayed — the sign-in did not bring it back. Unlike ResumePending, whose marker is deliberately preserved, there was nothing left to preserve. followup.Run now takes a pause predicate and skips the WHOLE sweep while it holds, without calling Due. The sweep body moved into a small function so the regression is testable directly instead of through a 30s ticker. Also from the review: - workKind.String() never reached production logs: both binaries use slog's JSONHandler, which marshals a named int as a number and never consults the Stringer — so the operator still read "kind":4, which is precisely what naming it was supposed to prevent. The value is now stringified at the call site. - Cancel and the attempt's own verdict both announced the same cancellation, one right behind the other. The session is marked cancelled and its verdict is suppressed; Cancel has already answered. - statusText told a group "the link and code arrive in a moment" for a login with no prompt yet — but a non-private destination never subscribes, so nothing would ever arrive there. - An empty CODEX_HOME is a misconfiguration no sign-in can fix: nowhere to write the credential, nowhere to read it. It used to fail at startup and now must again, instead of booting a deployment blocked forever whose successful /login reports saving nothing to a blank path. - The Telegram immediate reply still used the raw update context while VK had moved to a detached bounded sender; on /login cancel, which waits, the answer could vanish at shutdown. Left as a policy question for the deploy owner (unchanged, documented): /login force is available to any allow-listed user and rewrites the process credential, including during an active Codex run in the same CODEX_HOME. golangci-lint: 0 issues. Build, vet, full suite and -race green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/vk/receiver.go | 5 ++- cmd/flock-telegram/main.go | 16 +++++--- core/chat/rungate.go | 12 ++++++ core/codexauth/codexauth.go | 20 +++++++++- core/codexauth/codexauth_test.go | 37 ++++++++++-------- core/followup/followup.go | 27 ++++++++++++-- core/followup/sweep_test.go | 62 +++++++++++++++++++++++++++++++ internal/airunner/backend.go | 7 ++++ internal/airunner/backend_test.go | 23 ++++++++++++ internal/autonomy/autonomy.go | 6 ++- 10 files changed, 185 insertions(+), 30 deletions(-) create mode 100644 core/followup/sweep_test.go diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index 4e066dd..23702da 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -388,7 +388,10 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { default: // A kind added to classify but not handled here would otherwise fall through // to the text run and submit whatever is in text — possibly nothing. - r.logger.Warn("vk: unhandled work kind; dropping message", "kind", w.kind, "peer_id", peerID) + // .String() explicitly: both binaries log through slog's JSONHandler, which + // marshals a named int as a number — the Stringer would never be consulted and + // the operator would read "kind":4, exactly what naming it was meant to avoid. + r.logger.Warn("vk: unhandled work kind; dropping message", "kind", w.kind.String(), "peer_id", peerID) return } diff --git a/cmd/flock-telegram/main.go b/cmd/flock-telegram/main.go index fa91052..1919f71 100644 --- a/cmd/flock-telegram/main.go +++ b/cmd/flock-telegram/main.go @@ -1030,18 +1030,22 @@ func loginHandler(cfg config.Config, auth *codexauth.Manager) bot.HandlerFunc { if !ok { return } + // One bounded, detached sender for BOTH paths, as in the VK adapter: /login + // cancel waits out the login goroutine, so the update's own context can be + // gone by the time the reply is sent and the answer would vanish silently. + notify := func(text string) { + sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexauth.NotifyTimeout) + defer cancel() + sendCommandReply(sendCtx, b, chatID, text) + } sub := codexauth.Subscriber{ ID: chatIDStr(chatID), Private: update.Message.Chat.Type == models.ChatTypePrivate, - Notify: func(text string) { - sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexauth.NotifyTimeout) - defer cancel() - sendCommandReply(sendCtx, b, chatID, text) - }, + Notify: notify, } // An empty reply means Dispatch already delivered it through sub, in order. if reply := auth.Dispatch(ctx, commandArgs(update.Message.Text), sub); reply != "" { - sendCommandReply(ctx, b, chatID, reply) + notify(reply) } } } diff --git a/core/chat/rungate.go b/core/chat/rungate.go index 819e86d..0df6352 100644 --- a/core/chat/rungate.go +++ b/core/chat/rungate.go @@ -22,6 +22,18 @@ type RunGate interface { BlockedNotice() (string, bool) } +// RunsBlocked reports whether a run submitted right now would be refused. It +// lets a caller that would DESTROY work by submitting — the follow-up sweep takes +// each due item out of its store before firing it — skip the attempt entirely and +// try again later, instead of handing it to a gate that drops it. +func (s *Service) RunsBlocked() bool { + if s.auth == nil { + return false + } + _, blocked := s.auth.BlockedNotice() + return blocked +} + // blockSubmit reports whether a submission must be refused because the provider // is unauthorized, and tells the chat once why. // diff --git a/core/codexauth/codexauth.go b/core/codexauth/codexauth.go index ca2f465..0c410c1 100644 --- a/core/codexauth/codexauth.go +++ b/core/codexauth/codexauth.go @@ -143,8 +143,13 @@ type Manager struct { // session is one device-login attempt. The run goroutine owns its lifetime; every // field is guarded by Manager.mu. type session struct { - cancel context.CancelFunc - done chan struct{} // closed when this attempt's goroutine has returned + // cancelled records that this attempt was ended deliberately, so its verdict is + // not also announced: Cancel already told the chat, and "The Codex sign-in was + // cancelled" arriving after "Cancelled the pending Codex login" is the same news + // twice. + cancelled bool + cancel context.CancelFunc + done chan struct{} // closed when this attempt's goroutine has returned // prompt is the verification link and one-time code, once issued. prompt Prompt hasPrompt bool @@ -260,6 +265,10 @@ func (m *Manager) statusText(private bool) string { return pendingElsewhereText case cur != nil && hasPrompt: return "Codex device login is in progress — finish it in the browser:\n\n" + promptText(prompt) + case cur != nil && !private: + // Not "the code arrives in a moment": a non-private destination never + // subscribes, so nothing would ever arrive there. + return pendingElsewhereText case cur != nil: return "Codex device login is starting; the link and code arrive in a moment." case m.cfg.HasAccessToken: @@ -548,9 +557,15 @@ func (m *Manager) run(ctx context.Context, s *session) { if m.cur == s { m.cur = nil } + cancelled := s.cancelled subs := append([]Subscriber(nil), s.subs...) m.mu.Unlock() + if cancelled { + // Cancel already answered the user; the verdict would only repeat it. + log.Info("codex device login cancelled") + return + } deliverAll(subs, m.outcome(err, log)) } @@ -609,6 +624,7 @@ func (m *Manager) Cancel() bool { m.mu.Unlock() return false } + s.cancelled = true m.cur = nil m.mu.Unlock() diff --git a/core/codexauth/codexauth_test.go b/core/codexauth/codexauth_test.go index 882933b..e262a8a 100644 --- a/core/codexauth/codexauth_test.go +++ b/core/codexauth/codexauth_test.go @@ -320,8 +320,12 @@ func TestDispatchCancel(t *testing.T) { if got := m.Dispatch(context.Background(), "cancel", c.sub("chat-1")); !strings.Contains(got, "Cancelled") { t.Errorf("cancel = %q, want a cancellation acknowledgement", got) } - if got := c.next(t); !strings.Contains(got, "cancelled") { - t.Errorf("final notice = %q, want the cancellation reported", got) + // Told ONCE. The attempt's own verdict ("The Codex sign-in was cancelled") would + // land right behind Dispatch's acknowledgement and say the same thing again. + select { + case extra := <-c.ch: + t.Errorf("the cancellation was reported twice; extra notice: %q", extra) + case <-time.After(500 * time.Millisecond): } } @@ -493,7 +497,9 @@ func TestGroupStatusStillReportsSomethingUseful(t *testing.T) { // TestGroupSubscriberNeverJoinsTheBroadcast: a non-private destination must not // be subscribed either, or the prompt broadcast would hand it the code. func TestGroupSubscriberNeverJoinsTheBroadcast(t *testing.T) { - bin := fakeCodex(t, printBanner+"sleep 30\n") + // Runs to completion, so the private subscriber has a verdict to receive and the + // group's silence is a real result rather than nothing having happened. + bin := fakeCodex(t, printBanner+persistAuth+"exit 0\n") m := NewManager(Config{ Backend: BackendCodex, AuthMode: AuthSubscription, @@ -510,8 +516,7 @@ func TestGroupSubscriberNeverJoinsTheBroadcast(t *testing.T) { dm.awaitCode(t) m.Dispatch(context.Background(), "", Subscriber{ID: "group", Private: false, Notify: group.notify}) - m.Cancel() - if got := dm.next(t); !strings.Contains(got, "cancelled") { + if got := dm.next(t); !strings.Contains(strings.ToLower(got), "authorized") { t.Fatalf("the private subscriber did not get the outcome: %q", got) } select { @@ -824,21 +829,21 @@ func TestOutcomeGoesToTheAttemptThatProducedIt(t *testing.T) { // Cancel detaches attempt A; B starts while A is still unwinding. m.Cancel() m.Dispatch(context.Background(), "", second.sub("chat-b")) + second.awaitCode(t) - // A's cancellation reaches A, and never B. - found := false - for range 3 { - if strings.Contains(strings.ToLower(first.next(t)), "cancelled") { - found = true - break + // Nothing belonging to A may surface in B's chat. Reading the live subscriber + // list instead of A's own snapshot is what used to send A's verdict to B — + // seconds after B's "Starting the sign-in", where it reads as B's own result. + for _, text := range drain(second) { + low := strings.ToLower(text) + if strings.Contains(low, "cancelled") || strings.Contains(low, "failed") { + t.Errorf("the successor's chat received the predecessor's verdict: %q", text) } } - if !found { - t.Error("the cancelled attempt's own chat never heard its outcome") - } - for _, text := range drain(second) { + // And A, having been cancelled, was told exactly once — by Cancel itself. + for _, text := range drain(first) { if strings.Contains(strings.ToLower(text), "cancelled") { - t.Errorf("the successor's chat received the predecessor's verdict: %q", text) + t.Errorf("a cancelled attempt also announced its verdict: %q", text) } } } diff --git a/core/followup/followup.go b/core/followup/followup.go index deab1d9..b5b51ea 100644 --- a/core/followup/followup.go +++ b/core/followup/followup.go @@ -187,7 +187,14 @@ const tick = 30 * time.Second // Run fires due follow-ups until ctx is cancelled, handing each to fire (in // production a closure over chat.Service.InjectAuto). It returns ctx.Err() on // cancellation. -func Run(ctx context.Context, store *FileStore, now func() time.Time, fire func(Item)) error { +// +// paused reports that firing is impossible right now (in production: the AI +// provider is unauthorized). The WHOLE sweep is skipped while it holds — Due is +// not called at all. That is the point: Due is take-then-fire, removing what it +// returns, so sweeping into a fire that drops the item would delete scheduled +// work permanently. Skipping instead leaves each item in the store, due, to fire +// once the block clears. A nil paused never pauses. +func Run(ctx context.Context, store *FileStore, now func() time.Time, paused func() bool, fire func(Item)) error { if now == nil { now = time.Now } @@ -198,13 +205,25 @@ func Run(ctx context.Context, store *FileStore, now func() time.Time, fire func( case <-ctx.Done(): return ctx.Err() case <-t.C: - for _, it := range store.Due(now()) { - fire(it) - } + sweep(store, now(), paused, fire) } } } +// sweep fires everything due, unless firing is impossible right now. +// +// The pause check comes BEFORE Due deliberately: Due is take-then-fire, removing +// what it returns, so sweeping into a fire that would drop the item deletes +// scheduled work permanently. +func sweep(store *FileStore, now time.Time, paused func() bool, fire func(Item)) { + if paused != nil && paused() { + return + } + for _, it := range store.Due(now) { + fire(it) + } +} + // ParseDelay parses a follow-up filename stem ("15m", "2h", "90s", "1d") into // a delay clamped to [MinDelay, MaxDelay]. ok=false for anything that does not // parse as a positive Go-style duration (with "d" accepted as 24h). diff --git a/core/followup/sweep_test.go b/core/followup/sweep_test.go new file mode 100644 index 0000000..bb52a42 --- /dev/null +++ b/core/followup/sweep_test.go @@ -0,0 +1,62 @@ +//nolint:testpackage // whitebox: the sweep is unexported, and it is the unit under test. +package followup + +import ( + "path/filepath" + "testing" + "time" +) + +// TestSweepSkipsTheStoreWhilePaused is the data-loss regression. Due is +// take-then-fire: it REMOVES what it returns. Sweeping while firing is impossible +// would therefore delete each due follow-up permanently — on an unauthorized +// deployment every matured followup/.md would vanish, and the sign-in +// would not bring it back. Pausing has to skip the sweep, not just the fire. +func TestSweepSkipsTheStoreWhilePaused(t *testing.T) { + store, err := Open(filepath.Join(t.TempDir(), "followups.json")) + if err != nil { + t.Fatalf("open store: %v", err) + } + if _, err := store.Add("1", "check the deploy", time.UnixMilli(1)); err != nil { + t.Fatalf("add item: %v", err) + } + now := time.UnixMilli(1000) + + var fired []Item + sweep(store, now, func() bool { return true }, func(it Item) { fired = append(fired, it) }) + + if len(fired) != 0 { + t.Errorf("fired %+v while paused, want nothing", fired) + } + if got := store.Count("1"); got != 1 { + t.Fatalf("the store holds %d items while paused, want the item kept for later", got) + } + + // Unpaused, the same item fires — it survived the pause. + sweep(store, now, func() bool { return false }, func(it Item) { fired = append(fired, it) }) + + if len(fired) != 1 || fired[0].Prompt != "check the deploy" { + t.Errorf("fired %+v after the pause cleared, want the item that waited", fired) + } + if got := store.Count("1"); got != 0 { + t.Errorf("the store still holds %d items after firing, want them taken", got) + } +} + +// TestSweepWithoutAPausePredicate: a nil predicate never pauses. +func TestSweepWithoutAPausePredicate(t *testing.T) { + store, err := Open(filepath.Join(t.TempDir(), "followups.json")) + if err != nil { + t.Fatalf("open store: %v", err) + } + if _, err := store.Add("1", "go", time.UnixMilli(1)); err != nil { + t.Fatalf("add item: %v", err) + } + + var fired []Item + sweep(store, time.UnixMilli(1000), nil, func(it Item) { fired = append(fired, it) }) + + if len(fired) != 1 { + t.Errorf("fired %+v with a nil predicate, want the due item", fired) + } +} diff --git a/internal/airunner/backend.go b/internal/airunner/backend.go index 036168b..89b6182 100644 --- a/internal/airunner/backend.go +++ b/internal/airunner/backend.go @@ -105,6 +105,13 @@ func BuildWithPendingLogin(cfg config.Config) ( if err == nil || !IsPendingLogin(err) { return runner, opts, info, false, err } + // An empty CODEX_HOME cannot be fixed by signing in: there is nowhere for the + // credential to be written OR read, so the deployment would boot permanently + // blocked and a "successful" /login would report saving nothing, to a blank + // path. It used to fail at startup, and it still should. + if strings.TrimSpace(cfg.CodexHome) == "" { + return nil, agent.Options{}, agent.ProviderInfo{}, false, err + } relaxed := cfg relaxed.CodexRequireAuth = false runner, opts, info, err = Build(relaxed) diff --git a/internal/airunner/backend_test.go b/internal/airunner/backend_test.go index 74eeca2..3efc0c3 100644 --- a/internal/airunner/backend_test.go +++ b/internal/airunner/backend_test.go @@ -267,3 +267,26 @@ func TestCodexAuthConfigMirrorsTheRunner(t *testing.T) { } } } + +// TestEmptyCodexHomeStaysFatal: a blank CODEX_HOME is not "nobody has signed in +// yet", it is a misconfiguration no sign-in can fix — there is nowhere to write +// the credential and nowhere to read it back. Relaxing it would boot a deployment +// that is blocked forever, whose "successful" /login reports saving nothing to a +// blank path. +func TestEmptyCodexHomeStaysFatal(t *testing.T) { + // CodexHome is left at its zero value on purpose: that is the misconfiguration. + runner, _, _, pendingLogin, err := BuildWithPendingLogin(config.Config{ + AIBackend: config.AIBackendCodex, + CodexAuthMode: config.CodexAuthSubscription, + CodexRequireAuth: true, + }) + if !errors.Is(err, config.ErrCodexSubscriptionAuthRequired) { + t.Fatalf("error = %v, want the auth-required error to stay fatal", err) + } + if pendingLogin { + t.Error("pendingLogin = true for a deployment with nowhere to store a credential") + } + if runner != nil { + t.Error("a runner was built for a deployment that cannot ever authenticate") + } +} diff --git a/internal/autonomy/autonomy.go b/internal/autonomy/autonomy.go index 4a39cf3..161cca8 100644 --- a/internal/autonomy/autonomy.go +++ b/internal/autonomy/autonomy.go @@ -74,7 +74,11 @@ func StartFollowups(ctx context.Context, svc *chat.Service, pr chat.PostRunConfi return } go func() { - if err := followup.Run(ctx, pr.Followups, nil, func(it followup.Item) { + // svc.RunsBlocked pauses the sweep entirely while the provider is + // unauthorized. Without it the sweep would keep TAKING due items from the + // store and handing them to an InjectAuto that refuses them — scheduled work + // deleted for good, and not replayed after the sign-in. + if err := followup.Run(ctx, pr.Followups, nil, svc.RunsBlocked, func(it followup.Item) { svc.InjectAuto(ctx, it.ChatID, chat.FollowupPrompt(it)) }); err != nil && ctx.Err() == nil { logger.Error("follow-up loop stopped", "error", err) From 5be334e4b070c7a4020df8c02402bce5e1214349 Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 18:54:51 +0300 Subject: [PATCH 16/21] fix(codex): let the linter keep catching forgotten cases, and log refusals visibly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourteenth review round on #66. No functional bugs; four consistency and observability points, each pointing at a guard that was quietly weaker than it looked. - The default branch I added last round to catch a forgotten workKind actually DISABLED the check that would have caught it: .golangci.yml runs exhaustive with default-signifies-exhaustive, so a default makes the switch complete and a new constant stops failing the build. It traded a build error for a runtime log. Both switches are total again — the dispatch switch handles workText inline and warns after the switch, String() returns its fallback trailing — so exhaustive demands a case per constant, and the runtime backstop remains. - The user-path refusal was logged at Debug while core/chat logs the same refusal at Warn. But a user message never reaches core/chat, because the adapter answers and returns first — so at the default level an operator saw every background refusal and none of the human ones, which are the common case. Now Info in both adapters; the rate limiter above bounds the frequency. - HelpText/welcomeText were byte-identical wrappers in both adapters, differing only by the title constant — the same drift the strings themselves were moved to core/chat to prevent. The render moved too; the adapters keep a title and a one-line alias. - /login was advertised wherever Applicable held, which includes a deployment carrying CODEX_ACCESS_TOKEN — already authorized without a browser, so the menu led to a command that could only answer "not needed", the exact reason the other backends are excluded. LoginAdvertised now gates the menu and the help, while Applicable stays wider so /login force can still replace a workspace token with a personal subscription. golangci-lint: 0 issues. Build, vet, full suite and -race green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/telegram/commands.go | 16 +++------ adapters/vk/commands.go | 14 +++----- adapters/vk/receiver.go | 45 +++++++++++++++----------- cmd/flock-telegram/codex_login_test.go | 13 +++++--- cmd/flock-telegram/main.go | 10 +++--- core/chat/reserved.go | 12 +++++++ core/codexauth/codexauth.go | 16 +++++++++ core/codexauth/codexauth_test.go | 30 +++++++++++++++++ 8 files changed, 109 insertions(+), 47 deletions(-) diff --git a/adapters/telegram/commands.go b/adapters/telegram/commands.go index 2904ca4..add606b 100644 --- a/adapters/telegram/commands.go +++ b/adapters/telegram/commands.go @@ -89,15 +89,9 @@ func StripCommandMention(text, botUsername string) string { const helpTitle = "Flock Telegram assistant — available commands:\n\n" // HelpText renders the usage message replied to an allowed user who sends /help. -// login adds the /login line, on the same condition that publishes /login in -// the command menu. It is an engineering artifact (professional English, no duck -// flavor) and never reaches the Runner. -func HelpText(login chat.LoginVisibility) string { return helpTitle + chat.HelpBody(login) } +// It is a thin alias over the shared render, so this adapter owns only its title. +func HelpText(login chat.LoginVisibility) string { return chat.HelpText(helpTitle, login) } -// WelcomeText is the reply to /start: a short greeting prepended to the command -// help, so a brand-new user immediately sees what the bot does and how to use -// it. Like HelpText it never reaches the Runner — the duck greeting comes from -// the model on a real message. -func WelcomeText(login chat.LoginVisibility) string { - return chat.WelcomeGreeting + HelpText(login) -} +// WelcomeText is the reply to /start: the shared greeting prepended to the help, +// so a brand-new user immediately sees what the bot does and how to use it. +func WelcomeText(login chat.LoginVisibility) string { return chat.WelcomeText(helpTitle, login) } diff --git a/adapters/vk/commands.go b/adapters/vk/commands.go index 2e67c9e..ce4acfb 100644 --- a/adapters/vk/commands.go +++ b/adapters/vk/commands.go @@ -7,16 +7,12 @@ import "github.com/duckbugio/flock/core/chat" // slash-command UI, so these are plain text commands the receiver intercepts. const helpTitle = "Flock VK assistant — available commands:\n\n" -// HelpText renders the usage message. login adds the /login line, on the same -// condition that publishes /login in Telegram's command menu. It is an -// engineering artifact (professional English, no duck flavor). -func HelpText(login chat.LoginVisibility) string { return helpTitle + chat.HelpBody(login) } +// HelpText renders the usage message. It is a thin alias over the shared render, +// so this adapter owns only its title. +func HelpText(login chat.LoginVisibility) string { return chat.HelpText(helpTitle, login) } -// welcomeText is the reply to /start: a short greeting + the usage help, -// mirroring the Telegram adapter's WelcomeText. -func welcomeText(login chat.LoginVisibility) string { - return chat.WelcomeGreeting + HelpText(login) -} +// welcomeText is the reply to /start, mirroring the Telegram adapter. +func welcomeText(login chat.LoginVisibility) string { return chat.WelcomeText(helpTitle, login) } // goalUsageText is the /goal usage reply, mirroring the Telegram adapter. const goalUsageText = "Usage:\n" + diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index 23702da..3d2da74 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -360,12 +360,20 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { if w.kind != workNone { if notice, blocked := r.auth.BlockedNotice(); blocked { - r.logger.Debug("vk: codex unauthorized — blocking run", "peer_id", peerID) + // Info, not Debug: core/chat logs its refusals at Warn, but a user message + // never reaches it — this adapter answers and returns first. At the default + // level the operator would otherwise see every background refusal and none + // of the human ones. The rate limiter above bounds the frequency. + r.logger.Info("vk: codex unauthorized — refusing run", "peer_id", peerID) r.notify(ctx, peerID, notice) return } } + // No default branch on purpose: the exhaustive linter treats one as "all cases + // covered", which would trade a build-time error for a runtime log — and + // catching a forgotten kind at build time is the whole point. The fall-through + // below is the runtime backstop, kept for the same reason. switch w.kind { case workNone: return @@ -384,23 +392,21 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { r.handlePhoto(ctx, msg, w.photo, text) return case workText: - // Falls through to the text run below. - default: - // A kind added to classify but not handled here would otherwise fall through - // to the text run and submit whatever is in text — possibly nothing. - // .String() explicitly: both binaries log through slog's JSONHandler, which - // marshals a named int as a number — the Stringer would never be consulted and - // the operator would read "kind":4, exactly what naming it was meant to avoid. - r.logger.Warn("vk: unhandled work kind; dropping message", "kind", w.kind.String(), "peer_id", peerID) + // Fold any quoted/replied-to/forwarded original into the prompt so the run + // sees the context the user is referring to, not just their new text. + // QuotedPrompt is a strict no-op when there is no quote, so a normal message + // stays unchanged. + qAuthor, qText := quotedContext(msg) + text = chat.QuotedPrompt(qAuthor, qText, text) + r.svc.Handle(ctx, chatIDStr(peerID), msg.FromID, msgIDStr(msg.ConversationMessageID), text) return } - // Fold any quoted/replied-to/forwarded original into the prompt so the run sees - // the context the user is referring to, not just their new text. QuotedPrompt is - // a strict no-op when there is no quote, so a normal message stays unchanged. - qAuthor, qText := quotedContext(msg) - text = chat.QuotedPrompt(qAuthor, qText, text) - r.svc.Handle(ctx, chatIDStr(peerID), msg.FromID, msgIDStr(msg.ConversationMessageID), text) + // Only reachable for a kind added to classify and forgotten here — which the + // linter should have caught first. .String() explicitly: both binaries log + // through slog's JSONHandler, which marshals a named int as a number and never + // consults the Stringer. + r.logger.Warn("vk: unhandled work kind; dropping message", "kind", w.kind.String(), "peer_id", peerID) } // workKind names what an accepted message would submit. workNone means the @@ -431,9 +437,10 @@ func (k workKind) String() string { return "workDoc" case workPhoto: return "workPhoto" - default: - return "workKind(" + strconv.Itoa(int(k)) + ")" } + // Trailing rather than a default branch, so exhaustive keeps demanding a case + // per constant instead of letting a new kind render as its ordinal. + return "workKind(" + strconv.Itoa(int(k)) + ")" } // work is what an accepted message would submit: its kind, and the attachment the @@ -483,9 +490,9 @@ func (r *Receiver) dispatchReserved(ctx context.Context, name string, msg messag peerID := msg.PeerID switch name { case "start": - r.notify(ctx, peerID, welcomeText(chat.LoginVisibilityFor(r.auth.Applicable()))) + r.notify(ctx, peerID, welcomeText(chat.LoginVisibilityFor(r.auth.LoginAdvertised()))) case "help": - r.notify(ctx, peerID, HelpText(chat.LoginVisibilityFor(r.auth.Applicable()))) + r.notify(ctx, peerID, HelpText(chat.LoginVisibilityFor(r.auth.LoginAdvertised()))) case "new": if err := r.svc.NewSession(chatIDStr(peerID)); err != nil { r.logger.Error("vk: reset session", "peer_id", peerID, "error", err) diff --git a/cmd/flock-telegram/codex_login_test.go b/cmd/flock-telegram/codex_login_test.go index ddd28c8..489a3f4 100644 --- a/cmd/flock-telegram/codex_login_test.go +++ b/cmd/flock-telegram/codex_login_test.go @@ -307,10 +307,12 @@ var codexAuthForMenu = codexauth.NewManager(codexauth.Config{ AuthMode: codexauth.AuthSubscription, }) -// TestMenuOmitsLoginWithoutAnInteractiveSignIn: on Claude, an API-key backend or -// Codex billing there is no sign-in to complete, so advertising /login in the -// menu would only lead a user to a command that answers "not needed". The -// handler stays registered, so typing it still gets that answer. +// TestMenuOmitsLoginWithoutAnInteractiveSignIn: on Claude, an API-key backend, +// Codex billing, or a Codex deploy already carrying CODEX_ACCESS_TOKEN there is +// no sign-in to complete, so advertising /login would only lead a user to a +// command that answers "not needed". The handler stays registered, so typing it +// still gets that answer — and /login force still works, which is why the +// manager stays Applicable. func TestMenuOmitsLoginWithoutAnInteractiveSignIn(t *testing.T) { for _, tt := range []struct { name string @@ -320,6 +322,9 @@ func TestMenuOmitsLoginWithoutAnInteractiveSignIn(t *testing.T) { {"codex billing", codexauth.NewManager(codexauth.Config{ Backend: codexauth.BackendCodex, AuthMode: codexauth.AuthBilling, })}, + {"codex subscription with an access token", codexauth.NewManager(codexauth.Config{ + Backend: codexauth.BackendCodex, AuthMode: codexauth.AuthSubscription, HasAccessToken: true, + })}, {"no manager", nil}, } { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/flock-telegram/main.go b/cmd/flock-telegram/main.go index 1919f71..0ef10f2 100644 --- a/cmd/flock-telegram/main.go +++ b/cmd/flock-telegram/main.go @@ -561,7 +561,9 @@ func handleMessage(ctx context.Context, deps messageDeps, b *bot.Bot, msg *model // never asked for. hasWork := strings.TrimSpace(cleaned) != "" || isVoice || isDocument || isPhoto if notice, blocked := deps.auth.BlockedNotice(); blocked && hasWork { - slog.Debug("codex unauthorized — blocking run", "chat_id", msg.Chat.ID) + // Info, not Debug: see the VK adapter — core/chat's Warn never fires for a + // user message, because this path answers and returns first. + slog.Info("codex unauthorized — refusing run", "chat_id", msg.Chat.ID) sendCommandReply(ctx, b, msg.Chat.ID, notice) return } @@ -980,7 +982,7 @@ func reservedBotCommands(auth *codexauth.Manager) []models.BotCommand { // an API-key backend, Codex billing): it would sit in the menu only to answer // that it is not needed. The handler stays registered either way, so a user // who types it still gets that answer. - if c.Name == loginCommand && !auth.Applicable() { + if c.Name == loginCommand && !auth.LoginAdvertised() { continue } cmds = append(cmds, models.BotCommand{Command: c.Name, Description: c.Description}) @@ -1089,7 +1091,7 @@ func startHandler(cfg config.Config, auth *codexauth.Manager) bot.HandlerFunc { if !ok { return } - sendCommandReply(ctx, b, chatID, telegram.WelcomeText(chat.LoginVisibilityFor(auth.Applicable()))) + sendCommandReply(ctx, b, chatID, telegram.WelcomeText(chat.LoginVisibilityFor(auth.LoginAdvertised()))) } } @@ -1101,7 +1103,7 @@ func helpHandler(cfg config.Config, auth *codexauth.Manager) bot.HandlerFunc { if !ok { return } - sendCommandReply(ctx, b, chatID, telegram.HelpText(chat.LoginVisibilityFor(auth.Applicable()))) + sendCommandReply(ctx, b, chatID, telegram.HelpText(chat.LoginVisibilityFor(auth.LoginAdvertised()))) } } diff --git a/core/chat/reserved.go b/core/chat/reserved.go index 4776af5..129f1ca 100644 --- a/core/chat/reserved.go +++ b/core/chat/reserved.go @@ -66,6 +66,18 @@ func LoginVisibilityFor(applicable bool) LoginVisibility { return WithoutLogin } +// HelpText renders an adapter's /help: its own title line, then the shared body. +// The render lives here for the same reason its parts do — two adapters writing the +// same three-term concatenation drift as easily as two copies of the strings. +func HelpText(title string, login LoginVisibility) string { + return title + HelpBody(login) +} + +// WelcomeText renders an adapter's /start: the shared greeting, then its help. +func WelcomeText(title string, login LoginVisibility) string { + return WelcomeGreeting + HelpText(title, login) +} + // helpCommands lists the reserved commands with the fuller wording a chat reply // affords, next to the terse Description the command menu uses. const helpCommands = "/help — show this message\n" + diff --git a/core/codexauth/codexauth.go b/core/codexauth/codexauth.go index 0c410c1..d81cb86 100644 --- a/core/codexauth/codexauth.go +++ b/core/codexauth/codexauth.go @@ -170,6 +170,22 @@ func (m *Manager) Applicable() bool { strings.EqualFold(strings.TrimSpace(m.cfg.AuthMode), AuthSubscription) } +// LoginAdvertised reports whether /login should be OFFERED — in a command menu +// or a help text. It is narrower than Applicable: a deployment carrying +// CODEX_ACCESS_TOKEN is already authorized without a browser, so listing the +// command would lead the user to a reply that says it is not needed, which is the +// exact reason the other backends are excluded. +// +// Applicable stays wider on purpose: /login force is still a legitimate way to +// replace a workspace token with a personal subscription, so the command keeps +// working where it is simply not advertised. +func (m *Manager) LoginAdvertised() bool { + if m == nil { + return false + } + return m.Applicable() && !m.cfg.HasAccessToken +} + // Authorized reports whether Codex can run right now: a configured access token, // or a persisted auth.json under CODEX_HOME. It re-checks the file on every call // rather than caching a startup verdict, so the very first run after a diff --git a/core/codexauth/codexauth_test.go b/core/codexauth/codexauth_test.go index e262a8a..8e3fd76 100644 --- a/core/codexauth/codexauth_test.go +++ b/core/codexauth/codexauth_test.go @@ -957,3 +957,33 @@ func TestBlockedNoticePointsAtADirectMessage(t *testing.T) { t.Errorf("notice = %q, want it to name where /login is accepted", notice) } } + +// TestLoginAdvertisedNarrowerThanApplicable: a deployment carrying +// CODEX_ACCESS_TOKEN is authorized without a browser, so /login must not be +// offered there — it could only answer that it is not needed. The command itself +// stays available, because /login force is a legitimate way to replace a +// workspace token with a personal subscription. +func TestLoginAdvertisedNarrowerThanApplicable(t *testing.T) { + withToken := NewManager(Config{ + Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, + Home: t.TempDir(), HasAccessToken: true, + }) + if !withToken.Applicable() { + t.Error("Applicable() = false; /login force must stay usable with a token configured") + } + if withToken.LoginAdvertised() { + t.Error("LoginAdvertised() = true with an access token; the command would answer 'not needed'") + } + + plain := NewManager(Config{ + Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: t.TempDir(), + }) + if !plain.LoginAdvertised() { + t.Error("LoginAdvertised() = false on the deployment whose whole setup is /login") + } + + var nilManager *Manager + if nilManager.LoginAdvertised() { + t.Error("a nil Manager advertised /login") + } +} From bb406e9fd9f0c1304d79be4784c16d21f4086f21 Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 19:08:03 +0300 Subject: [PATCH 17/21] fix(codex): drop the linter scaffolding, name the blocked user, sharpen the .env note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifteenth review round on #66. All cosmetic; net −47 lines. - The unreachable backstop after the workKind switch, workKind.String(), and its test came to about fifty lines serving a state the exhaustive linter already makes impossible — infrastructure for an analyzer rather than part of finishing a Codex sign-in from chat. Removed; every case returns, so the switch needs nothing after it. This unwinds three rounds of my own churn: I added the default branch, then a Stringer for its log, then learned the Stringer never reaches a JSON log. The reviewer's point is that none of it should have existed. - The refusal log named only peer_id, while every other refusal in the same function names user_id — and for THIS one the user is the answer to the operator's actual question: who hit the closed gate and should go run /login. Added in both adapters. - .env.example read as though CODEX_REQUIRE_AUTH=false were a second way to get a working deployment. It only removes the gate: without auth.json or CODEX_ACCESS_TOKEN the run then fails inside the CLI instead. Reworded in both the example and the deploy template so they cannot drift. - vk.HelpText had no consumer outside its package, unlike telegram.HelpText, and sat next to a welcomeText this PR had just made private. Unexported. golangci-lint: 0 issues. Build, vet, full suite and -race green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/telegram/.env.example | 3 +- .../roles/claude_tg_bot/templates/env.j2 | 4 +- adapters/vk/commands.go | 7 ++-- adapters/vk/receiver.go | 37 +++---------------- adapters/vk/receiver_test.go | 36 +++--------------- cmd/flock-telegram/main.go | 2 +- 6 files changed, 21 insertions(+), 68 deletions(-) diff --git a/adapters/telegram/.env.example b/adapters/telegram/.env.example index dbf515d..e7e1454 100644 --- a/adapters/telegram/.env.example +++ b/adapters/telegram/.env.example @@ -24,7 +24,8 @@ AI_BACKEND=claude # claude | codex | openai-compatible # First sign-in needs no shell on the host: the bot starts unauthorized and an # allow-listed user sends /login IN A DIRECT MESSAGE (the reply carries a one-time # code, so starting a sign-in is refused in group chats). Runs are blocked until -# it completes, unless CODEX_REQUIRE_AUTH=false. +# it completes. CODEX_REQUIRE_AUTH=false only removes the block — without +# auth.json or CODEX_ACCESS_TOKEN runs then fail inside the Codex CLI instead. # Full flow, and who ends up owning the Codex account: # docs/codex-integration-plan.md#subscription-setup CODEX_AUTH_MODE=subscription # subscription | billing diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 b/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 index 923a568..f956cd5 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 +++ b/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 @@ -31,7 +31,9 @@ CLAUDE_MAX_COST_PER_REQUEST={{ claude_max_cost_per_request }} # subscription: use persisted {{ codex_home }}/auth.json or CODEX_ACCESS_TOKEN. # With neither, the bot starts unauthorized and an allow-listed user completes # the one-time sign-in from the chat with /login IN A DIRECT MESSAGE. Runs are -# blocked until it lands, unless CODEX_REQUIRE_AUTH is false. +# blocked until it lands. CODEX_REQUIRE_AUTH=false only removes the block — +# without auth.json or CODEX_ACCESS_TOKEN runs then fail inside the Codex CLI +# instead. # Full flow, and who ends up owning the Codex account: # docs/codex-integration-plan.md#subscription-setup # billing: requires CODEX_API_KEY and CODEX_BILLING_ACK=true. diff --git a/adapters/vk/commands.go b/adapters/vk/commands.go index ce4acfb..0fa1db8 100644 --- a/adapters/vk/commands.go +++ b/adapters/vk/commands.go @@ -7,9 +7,10 @@ import "github.com/duckbugio/flock/core/chat" // slash-command UI, so these are plain text commands the receiver intercepts. const helpTitle = "Flock VK assistant — available commands:\n\n" -// HelpText renders the usage message. It is a thin alias over the shared render, -// so this adapter owns only its title. -func HelpText(login chat.LoginVisibility) string { return chat.HelpText(helpTitle, login) } +// helpText renders the usage message. It is a thin alias over the shared render, +// so this adapter owns only its title. Unexported like welcomeText beside it: +// nothing outside this package renders VK's help. +func helpText(login chat.LoginVisibility) string { return chat.HelpText(helpTitle, login) } // welcomeText is the reply to /start, mirroring the Telegram adapter. func welcomeText(login chat.LoginVisibility) string { return chat.WelcomeText(helpTitle, login) } diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index 3d2da74..d247a59 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -364,16 +364,16 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { // never reaches it — this adapter answers and returns first. At the default // level the operator would otherwise see every background refusal and none // of the human ones. The rate limiter above bounds the frequency. - r.logger.Info("vk: codex unauthorized — refusing run", "peer_id", peerID) + r.logger.Info("vk: codex unauthorized — refusing run", "peer_id", peerID, "user_id", msg.FromID) r.notify(ctx, peerID, notice) return } } // No default branch on purpose: the exhaustive linter treats one as "all cases - // covered", which would trade a build-time error for a runtime log — and - // catching a forgotten kind at build time is the whole point. The fall-through - // below is the runtime backstop, kept for the same reason. + // covered", so a new workKind without a case here would stop failing the build — + // which is the check worth having. Every case returns, so there is nothing after + // the switch. switch w.kind { case workNone: return @@ -401,12 +401,6 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { r.svc.Handle(ctx, chatIDStr(peerID), msg.FromID, msgIDStr(msg.ConversationMessageID), text) return } - - // Only reachable for a kind added to classify and forgotten here — which the - // linter should have caught first. .String() explicitly: both binaries log - // through slog's JSONHandler, which marshals a named int as a number and never - // consults the Stringer. - r.logger.Warn("vk: unhandled work kind; dropping message", "kind", w.kind.String(), "peer_id", peerID) } // workKind names what an accepted message would submit. workNone means the @@ -422,27 +416,6 @@ const ( workPhoto ) -// String names the kind, so the diagnostic in the dispatch switch reads -// "kind=workPhoto" instead of a bare ordinal an operator would have to look up in -// this file — the branch exists precisely to report a kind nobody handled. -func (k workKind) String() string { - switch k { - case workNone: - return "workNone" - case workText: - return "workText" - case workVoice: - return "workVoice" - case workDoc: - return "workDoc" - case workPhoto: - return "workPhoto" - } - // Trailing rather than a default branch, so exhaustive keeps demanding a case - // per constant instead of letting a new kind render as its ordinal. - return "workKind(" + strconv.Itoa(int(k)) + ")" -} - // work is what an accepted message would submit: its kind, and the attachment the // matching handler needs. Carrying the attachment is the point — the handlers // dereference it unconditionally, so finding it a second time in the dispatch @@ -492,7 +465,7 @@ func (r *Receiver) dispatchReserved(ctx context.Context, name string, msg messag case "start": r.notify(ctx, peerID, welcomeText(chat.LoginVisibilityFor(r.auth.LoginAdvertised()))) case "help": - r.notify(ctx, peerID, HelpText(chat.LoginVisibilityFor(r.auth.LoginAdvertised()))) + r.notify(ctx, peerID, helpText(chat.LoginVisibilityFor(r.auth.LoginAdvertised()))) case "new": if err := r.svc.NewSession(chatIDStr(peerID)); err != nil { r.logger.Error("vk: reset session", "peer_id", peerID, "error", err) diff --git a/adapters/vk/receiver_test.go b/adapters/vk/receiver_test.go index e77bd97..95ef1f3 100644 --- a/adapters/vk/receiver_test.go +++ b/adapters/vk/receiver_test.go @@ -449,7 +449,7 @@ func TestReceiverHelpTextCommand(t *testing.T) { if len(svc.handleCalls) != 0 { t.Errorf("/help should not start a run, got %d Handle calls", len(svc.handleCalls)) } - if len(notices.texts) != 1 || notices.texts[0] != HelpText(chat.WithoutLogin) { + if len(notices.texts) != 1 || notices.texts[0] != helpText(chat.WithoutLogin) { t.Errorf("notice texts = %v, want one HelpText notice", notices.texts) } } @@ -818,10 +818,10 @@ func TestReceiverLoginRejectsDisallowedSender(t *testing.T) { // line appears only where an interactive sign-in exists, so the two adapters (and // Telegram's command menu) cannot disagree. func TestHelpListsLoginOnlyWhereItApplies(t *testing.T) { - if !strings.Contains(HelpText(chat.WithLogin), "/login") { + if !strings.Contains(helpText(chat.WithLogin), "/login") { t.Error("help omits /login where the sign-in is real") } - if strings.Contains(HelpText(chat.WithoutLogin), "/login") { + if strings.Contains(helpText(chat.WithoutLogin), "/login") { t.Error("help advertises /login where there is no sign-in") } if strings.Contains(welcomeText(chat.WithoutLogin), "/login") { @@ -870,7 +870,7 @@ func TestPrivacyFollowsTheDirectMessageInvariant(t *testing.T) { // drift silently — the adapters would still share the applicability predicate but // not the words. func TestHelpLineComesFromTheCanonicalSet(t *testing.T) { - if !strings.Contains(HelpText(chat.WithLogin), chat.LoginHelpLine) { + if !strings.Contains(helpText(chat.WithLogin), chat.LoginHelpLine) { t.Error("the VK help does not render the canonical /login line") } } @@ -954,7 +954,7 @@ func TestClassifyDrivesBothGateAndDispatch(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := r.classify(tt.text, tt.msg); got.kind != tt.want { - t.Errorf("classify kind = %v, want %v", got.kind, tt.want) + t.Errorf("classify kind = %d, want %d", got.kind, tt.want) } }) } @@ -977,33 +977,9 @@ func TestClassifyCarriesTheAttachment(t *testing.T) { Type: "doc", Doc: &docAttachment{URL: "https://vk.example/doc", Title: "notes.txt"}, }}}) if got.kind != workDoc { - t.Fatalf("kind = %v, want workDoc", got.kind) + t.Fatalf("kind = %d, want workDoc", got.kind) } if got.doc == nil { t.Fatal("classify returned workDoc with no document; the handler would panic") } } - -// TestWorkKindString: the dispatch switch's default branch exists to report a -// kind nobody handled, so it has to name it — a bare ordinal sends the operator -// to the source to decode their own log line. -func TestWorkKindString(t *testing.T) { - tests := []struct { - kind workKind - want string - }{ - {workNone, "workNone"}, - {workText, "workText"}, - {workVoice, "workVoice"}, - {workDoc, "workDoc"}, - {workPhoto, "workPhoto"}, - {workKind(42), "workKind(42)"}, - } - for _, tt := range tests { - t.Run(tt.want, func(t *testing.T) { - if got := tt.kind.String(); got != tt.want { - t.Errorf("String() = %q, want %q", got, tt.want) - } - }) - } -} diff --git a/cmd/flock-telegram/main.go b/cmd/flock-telegram/main.go index 0ef10f2..be52f76 100644 --- a/cmd/flock-telegram/main.go +++ b/cmd/flock-telegram/main.go @@ -563,7 +563,7 @@ func handleMessage(ctx context.Context, deps messageDeps, b *bot.Bot, msg *model if notice, blocked := deps.auth.BlockedNotice(); blocked && hasWork { // Info, not Debug: see the VK adapter — core/chat's Warn never fires for a // user message, because this path answers and returns first. - slog.Info("codex unauthorized — refusing run", "chat_id", msg.Chat.ID) + slog.Info("codex unauthorized — refusing run", "chat_id", msg.Chat.ID, "user_id", msg.From.ID) sendCommandReply(ctx, b, msg.Chat.ID, notice) return } From 8a264949f3674d43aee6a260cec5a8a4cc83ec45 Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 19:22:53 +0300 Subject: [PATCH 18/21] fix(codex): stop charging the user's rate limit for a run that cannot happen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixteenth review round on #66. The unauthorized gate sat after the guards, so CheckGuards counted every message that was certain to be refused — a user could exhaust their window on refusals and then hit the limit with their first real message after signing in. I had put it there deliberately, to let the rate limiter throttle the notice; the better trade is to check authorization first (one os.Stat, no paid work before it) and supply the restraint directly. That restraint is now chat.OnceNotifier: each chat is told once while the condition holds, and every chat is forgotten when it clears, so a returning outage is announced afresh. core/chat's own gate had this logic inline and now shares the type, rather than each adapter growing a third copy of it. Also: classify's doc claimed it walks the attachments once, which it does not — up to three helpers each scan the slice. The single DECISION is the point, not the number of passes, so the comment now says that. Noted from the review, unchanged and consistent with existing behaviour: a cron fire refused by the gate is not replayed after the sign-in (schedule.Manager marks it fired regardless, exactly as it does for the cost cap), and pending markers replay on the next restart rather than the moment /login lands. The CI failure on the previous push was TestProgressEditsRespectMinInterval again — untouched by this branch, documented as starvation-prone, green on re-run. Second occurrence; worth hardening separately. golangci-lint: 0 issues. Build, vet, full suite and -race green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/vk/receiver.go | 58 ++++++++++++++++-------------------- adapters/vk/receiver_test.go | 55 ++++++++++++++++++++++++++++++++++ cmd/flock-telegram/main.go | 50 +++++++++++++++++-------------- core/chat/oncenotice.go | 48 +++++++++++++++++++++++++++++ core/chat/rungate.go | 19 ++++-------- core/chat/rungate_test.go | 27 +++++++++++++++++ core/chat/service.go | 51 +++++++++++++++---------------- 7 files changed, 215 insertions(+), 93 deletions(-) create mode 100644 core/chat/oncenotice.go diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index d247a59..17d172b 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -112,6 +112,7 @@ type Receiver struct { eventAck eventAckFunc sched *schedule.Manager auth *codexauth.Manager + authNotified *chat.OnceNotifier logger *slog.Logger } @@ -162,6 +163,7 @@ func NewReceiver(cfg ReceiverConfig) *Receiver { eventAck: cfg.EventAck, sched: cfg.Scheduler, auth: cfg.CodexAuth, + authNotified: chat.NewOnceNotifier(), logger: log, } } @@ -330,42 +332,32 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { return } - if r.guards != nil { - if allow, reason := r.guards(msg.FromID); !allow { - r.logger.Debug("vk: guardrail denied message", "peer_id", peerID, "user_id", msg.FromID, "reason", reason) - r.notify(ctx, peerID, reason) - return - } - } - - // Codex with no completed sign-in cannot run anything. Say so here — after the - // mention gate (so an unaddressed community message stays silent) and after the - // guards (an unauthorized deploy in a busy conversation would otherwise answer - // every single message, unthrottled), but before any paid work: the guards spend - // nothing, while transcription and downloads do. /login is dispatched above and - // stays reachable while this gate is closed. core/chat gates the run itself; - // this is the early, user-facing half. - // - // Only for a message that WOULD have started a run: a sticker, an empty message - // or an unsupported attachment is dropped silently further down, and answering - // those with "not authorized" spends rate limit to tell the user about work they - // never asked for. text := strings.TrimSpace(cleaned) - // Decided ONCE, and used by both the gate and the dispatch below. Two - // independent condition lists would agree only until the next attachment type is - // added: the new branch would run, but the gate would not know about it, so an - // unauthorized deploy would drop that message silently instead of pointing at - // /login. + // Decided ONCE, and used by both the gate and the dispatch below. w := r.classify(text, msg) + // Authorization is checked BEFORE the guards, and so before the rate limiter + // counts this message: a run that cannot happen must not spend the user's + // window, or the first real messages after a successful /login would hit the + // limit. The check is one os.Stat and no paid work precedes it. The notice is + // sent once per peer until authorization returns — dropping the limiter as a + // throttle means supplying the restraint here, exactly as core/chat does for + // its own refusals. if w.kind != workNone { if notice, blocked := r.auth.BlockedNotice(); blocked { - // Info, not Debug: core/chat logs its refusals at Warn, but a user message - // never reaches it — this adapter answers and returns first. At the default - // level the operator would otherwise see every background refusal and none - // of the human ones. The rate limiter above bounds the frequency. r.logger.Info("vk: codex unauthorized — refusing run", "peer_id", peerID, "user_id", msg.FromID) - r.notify(ctx, peerID, notice) + if r.authNotified.Should(chatIDStr(peerID)) { + r.notify(ctx, peerID, notice) + } + return + } + r.authNotified.Clear() + } + + if r.guards != nil { + if allow, reason := r.guards(msg.FromID); !allow { + r.logger.Debug("vk: guardrail denied message", "peer_id", peerID, "user_id", msg.FromID, "reason", reason) + r.notify(ctx, peerID, reason) return } } @@ -428,8 +420,10 @@ type work struct { photo *photoAttachment } -// classify decides what an accepted message would submit, walking the -// attachments ONCE. text is the mention-stripped, trimmed body. The precedence is +// classify decides what an accepted message would submit. The decision is made +// ONCE and used by both the unauthorized gate and the dispatch switch — that +// single decision is the point, not the number of passes over the slice. text is +// the mention-stripped, trimmed body. The precedence is // the dispatch order: a voice note counts only while there is no text to run // instead, and attachments only when an uploader is configured. func (r *Receiver) classify(text string, msg messageObject) work { diff --git a/adapters/vk/receiver_test.go b/adapters/vk/receiver_test.go index 95ef1f3..86781e7 100644 --- a/adapters/vk/receiver_test.go +++ b/adapters/vk/receiver_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "os" "path/filepath" "strings" "sync" @@ -983,3 +984,57 @@ func TestClassifyCarriesTheAttachment(t *testing.T) { t.Fatal("classify returned workDoc with no document; the handler would panic") } } + +// TestUnauthorizedNoticeIsSentOncePerPeer: without the rate limiter throttling +// it — the gate now runs before the guards, so a refused message costs the user +// nothing — the restraint has to live here. A busy conversation would otherwise +// get a refusal for every message. +func TestUnauthorizedNoticeIsSentOncePerPeer(t *testing.T) { + svc := &fakeService{} + notices := &fakeNotice{} + r := unauthorizedCodexReceiver(t, svc, notices) + + for range 5 { + r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 42, Text: "build it"})) + } + + if got := notices.awaitOne(t); !strings.Contains(got[0], "/login") { + t.Errorf("notice = %q, want it to point at /login", got[0]) + } + if len(svc.handleCalls) != 0 { + t.Errorf("started %d runs while unauthorized, want 0", len(svc.handleCalls)) + } +} + +// TestUnauthorizedNoticeReturnsAfterAuthorization: told once, but told again the +// next time the deployment lapses — otherwise a returning outage is silent. +func TestUnauthorizedNoticeReturnsAfterAuthorization(t *testing.T) { + svc := &fakeService{} + notices := &fakeNotice{} + auth, home := logintest.Unauthorized(t) + r := newTestReceiverWithAuth(svc, notices, auth) + + send := func() { + r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 42, Text: "build it"})) + } + send() + notices.awaitOne(t) + + // Authorized: the message runs, and the "already told them" memory resets. + if err := os.WriteFile(filepath.Join(home, "auth.json"), []byte("{}"), 0o600); err != nil { + t.Fatalf("write auth.json: %v", err) + } + send() + if len(svc.handleCalls) != 1 { + t.Fatalf("Handle calls = %d, want 1 once authorized", len(svc.handleCalls)) + } + + // Lapsed again: announced afresh. + if err := os.Remove(filepath.Join(home, "auth.json")); err != nil { + t.Fatalf("remove auth.json: %v", err) + } + send() + if got := notices.seen(); len(got) != 2 { + t.Errorf("notices = %v, want the returning lapse announced again", got) + } +} diff --git a/cmd/flock-telegram/main.go b/cmd/flock-telegram/main.go index be52f76..a737518 100644 --- a/cmd/flock-telegram/main.go +++ b/cmd/flock-telegram/main.go @@ -422,6 +422,7 @@ func textHandler( guards chat.GuardConfig, auth *codexauth.Manager, ) bot.HandlerFunc { + authNotified := chat.NewOnceNotifier() return func(ctx context.Context, b *bot.Bot, update *models.Update) { service := *svc if service == nil { @@ -432,14 +433,17 @@ func textHandler( // Voice and media uploads apply only to new messages, never edits, so // neither the transcriber nor the uploader is threaded here. if edited := update.EditedMessage; edited != nil { - deps := messageDeps{cfg: cfg, service: service, limiter: limiter, costs: costs, guards: guards, auth: auth} + deps := messageDeps{ + cfg: cfg, service: service, limiter: limiter, costs: costs, + guards: guards, auth: auth, authNotified: authNotified, + } handleMessage(ctx, deps, b, edited, true) return } if msg := update.Message; msg != nil { deps := messageDeps{ cfg: cfg, service: service, vt: *vt, up: *up, - limiter: limiter, costs: costs, guards: guards, auth: auth, + limiter: limiter, costs: costs, guards: guards, auth: auth, authNotified: authNotified, } handleMessage(ctx, deps, b, msg, false) } @@ -479,6 +483,8 @@ type messageDeps struct { // auth blocks runs while the Codex backend has no completed sign-in. Nil (and // a nil *Manager) means "never blocks". auth *codexauth.Manager + // authNotified suppresses a repeat unauthorized notice per chat. + authNotified *chat.OnceNotifier } // handleMessage applies the allow-list and the group mention-gate to one message @@ -541,33 +547,31 @@ func handleMessage(ctx context.Context, deps messageDeps, b *bot.Bot, msg *model // call and is never submitted to the dispatcher; the user gets a single // professional notice. Disallowed users already returned above, so the guards // only ever see allow-listed senders. + // Codex with no completed sign-in cannot run anything. The check comes BEFORE + // the guards, and so before the rate limiter counts this message: a run that + // cannot happen must not spend the user's window, or the first real messages + // after a successful /login would hit the limit. It is one os.Stat, and no paid + // work precedes it. Only for a message that WOULD have run — a sticker or an + // empty message is dropped silently below, and answering those would explain + // work nobody asked for. The notice is sent once per chat until authorization + // returns; without the limiter throttling it, the restraint belongs here. + if strings.TrimSpace(cleaned) != "" || isVoice || isDocument || isPhoto { + if notice, blocked := deps.auth.BlockedNotice(); blocked { + slog.Info("codex unauthorized — refusing run", "chat_id", msg.Chat.ID, "user_id", msg.From.ID) + if deps.authNotified.Should(chatIDStr(msg.Chat.ID)) { + sendCommandReply(ctx, b, msg.Chat.ID, notice) + } + return + } + deps.authNotified.Clear() + } + if allow, reason := chat.CheckGuards(limiter, costs, guards, msg.From.ID); !allow { slog.Debug("guardrail denied message", "chat_id", msg.Chat.ID, "user_id", msg.From.ID, "reason", reason) sendCommandReply(ctx, b, msg.Chat.ID, reason) return } - // Codex with no completed sign-in cannot run anything. Say so here — after the - // mention gate (so an unaddressed group message stays silent) and after the rate - // limit (an unauthorized deploy in a busy group would otherwise answer every - // single message, unthrottled), but before any paid work: the guards spend - // nothing, while transcription and downloads do. /login is a reserved command - // routed elsewhere, so it stays reachable while this gate is closed. core/chat - // gates the run itself; this is the early, user-facing half. - // - // Only for a message that WOULD have started a run: a sticker, an empty message - // or an unsupported attachment is dropped silently further down, and answering - // those with "not authorized" spends rate limit to tell the user about work they - // never asked for. - hasWork := strings.TrimSpace(cleaned) != "" || isVoice || isDocument || isPhoto - if notice, blocked := deps.auth.BlockedNotice(); blocked && hasWork { - // Info, not Debug: see the VK adapter — core/chat's Warn never fires for a - // user message, because this path answers and returns first. - slog.Info("codex unauthorized — refusing run", "chat_id", msg.Chat.ID, "user_id", msg.From.ID) - sendCommandReply(ctx, b, msg.Chat.ID, notice) - return - } - text := strings.TrimSpace(cleaned) // Normalize the group form of a forwarded slash command so an unknown command diff --git a/core/chat/oncenotice.go b/core/chat/oncenotice.go new file mode 100644 index 0000000..138351c --- /dev/null +++ b/core/chat/oncenotice.go @@ -0,0 +1,48 @@ +package chat + +import "sync" + +// OnceNotifier tells each destination something at most once, until the +// condition that prompted it clears. +// +// It exists for notices that would otherwise repeat on every message while a +// deployment-wide condition holds — today, "the provider is not authorized". The +// first message from a chat earns an explanation; the next fifty do not, because +// the user has already been told and the answer has not changed. Clear resets +// every destination at once, so a condition that returns is announced afresh. +// +// The zero value is not usable; call NewOnceNotifier. A nil *OnceNotifier always +// reports true, so a caller that never wired one keeps its old behaviour. +type OnceNotifier struct { + mu sync.Mutex + told map[string]bool +} + +// NewOnceNotifier returns an empty notifier. +func NewOnceNotifier() *OnceNotifier { + return &OnceNotifier{told: map[string]bool{}} +} + +// Should reports whether id still needs to be told, marking it as told. +func (n *OnceNotifier) Should(id string) bool { + if n == nil { + return true + } + n.mu.Lock() + defer n.mu.Unlock() + if n.told[id] { + return false + } + n.told[id] = true + return true +} + +// Clear forgets every destination, so the next occurrence is announced again. +func (n *OnceNotifier) Clear() { + if n == nil { + return + } + n.mu.Lock() + defer n.mu.Unlock() + clear(n.told) +} diff --git a/core/chat/rungate.go b/core/chat/rungate.go index 0df6352..6ea84e2 100644 --- a/core/chat/rungate.go +++ b/core/chat/rungate.go @@ -59,24 +59,17 @@ func (s *Service) blockSubmit(ctx context.Context, chatID ChatID) bool { } notice, blocked := s.auth.BlockedNotice() if !blocked { - // Clear EVERY chat, not just this one: authorization is process-wide, so one - // chat submitting is proof the outage is over for all of them. Clearing only - // the submitting chat would leave a chat that stayed quiet through the - // recovery window silently un-notified when the next lapse hit it. - s.mu.Lock() - clear(s.authNotified) - s.mu.Unlock() + // Every chat, not just this one: authorization is process-wide, so one chat + // submitting is proof the outage is over for all of them. Clearing only the + // submitting chat would leave a chat that stayed quiet through the recovery + // window silently un-notified when the next lapse hit it. + s.authNotified.Clear() return false } s.log.Warn("provider is not authorized — refusing run", "chat_id", chatID) - s.mu.Lock() - first := !s.authNotified[chatID] - s.authNotified[chatID] = true - s.mu.Unlock() - - if first && notice != "" { + if first := s.authNotified.Should(chatID); first && notice != "" { // Via notify, which bounds the send: this runs on the CALLER's goroutine, and // three of the six callers are loops that must not be stalled — the PR poller // dispatches inline, and the restart replay walks every stored marker. A diff --git a/core/chat/rungate_test.go b/core/chat/rungate_test.go index 9ddedab..b974663 100644 --- a/core/chat/rungate_test.go +++ b/core/chat/rungate_test.go @@ -257,3 +257,30 @@ func TestNotifyResetIsProcessWide(t *testing.T) { s.Inject(testChatID, "blocked again") waitUntil(t, func() bool { return notices(c) == 2 }) } + +// TestOnceNotifier covers the shared restraint directly: told once per +// destination, everyone forgotten on Clear, and a nil notifier inert. +func TestOnceNotifier(t *testing.T) { + n := NewOnceNotifier() + + if !n.Should("a") { + t.Error("first ask for a = false, want true") + } + if n.Should("a") { + t.Error("second ask for a = true, want false") + } + if !n.Should("b") { + t.Error("first ask for b = false; destinations are independent") + } + + n.Clear() + if !n.Should("a") { + t.Error("after Clear, a was still marked as told") + } + + var nilNotifier *OnceNotifier + if !nilNotifier.Should("a") { + t.Error("a nil notifier suppressed a notice; it must stay inert") + } + nilNotifier.Clear() +} diff --git a/core/chat/service.go b/core/chat/service.go index f903344..9a09b03 100644 --- a/core/chat/service.go +++ b/core/chat/service.go @@ -129,13 +129,14 @@ type Service struct { tick time.Duration nowFunc func() time.Time auth RunGate + // authNotified suppresses a repeat "provider unauthorized" notice per chat. + authNotified *OnceNotifier - mu sync.Mutex // guards runChat, lastMsg, verifyRetry, budgetNotified, authNotified and snapCache + mu sync.Mutex // guards runChat, lastMsg, verifyRetry, budgetNotified and snapCache runChat map[string]ChatID // active runID -> chatID, for mapping Stop back to a chat lastMsg map[ChatID]MessageID // chatID -> the source message id of its latest submitted run verifyRetry map[ChatID][]string // repos whose gate failed last round (re-gated even if unchanged) budgetNotified map[ChatID]string // chatID -> UTC day the budget-reached notice was last sent - authNotified map[ChatID]bool // chatID -> the "provider unauthorized" notice was already sent snapCache map[ChatID]verify.Snapshot // post-run repo fingerprints, reused as the next run's "before" runSeq atomic.Uint64 } @@ -209,32 +210,32 @@ func New(cfg Config) *Service { maxRunes = defaultMaxMessageRunes } return &Service{ - runner: cfg.Runner, - chat: cfg.Transport, - caps: caps, - retryAfter: cfg.RetryAfter, - maxRunes: maxRunes, - dispatch: cfg.Dispatcher, - workspace: cfg.Workspace, - sessions: cfg.Sessions, - pending: cfg.Pending, - costs: cfg.Costs, - costCapUSD: cfg.CostCapUSD, - outbox: cfg.Outbox, - nudge: newStarNudge(cfg.StarNudge, cfg.Transport, log), - postrun: cfg.PostRun, - opts: cfg.Opts, - timeout: cfg.Timeout, - log: log, - tick: tickInterval, - nowFunc: time.Now, - auth: cfg.Auth, - runChat: map[string]ChatID{}, - lastMsg: map[ChatID]MessageID{}, + runner: cfg.Runner, + chat: cfg.Transport, + caps: caps, + retryAfter: cfg.RetryAfter, + maxRunes: maxRunes, + dispatch: cfg.Dispatcher, + workspace: cfg.Workspace, + sessions: cfg.Sessions, + pending: cfg.Pending, + costs: cfg.Costs, + costCapUSD: cfg.CostCapUSD, + outbox: cfg.Outbox, + nudge: newStarNudge(cfg.StarNudge, cfg.Transport, log), + postrun: cfg.PostRun, + opts: cfg.Opts, + timeout: cfg.Timeout, + log: log, + tick: tickInterval, + nowFunc: time.Now, + auth: cfg.Auth, + authNotified: NewOnceNotifier(), + runChat: map[string]ChatID{}, + lastMsg: map[ChatID]MessageID{}, verifyRetry: map[ChatID][]string{}, budgetNotified: map[ChatID]string{}, - authNotified: map[ChatID]bool{}, snapCache: map[ChatID]verify.Snapshot{}, } } From b3038552f043786c6cd4f7f591f3da4eab18f43b Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 19:58:21 +0300 Subject: [PATCH 19/21] fix(codex): pause the cron tick too, so a blocked fire does not lose its slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventeenth review round on #66. A cron fire refused by the gate was lost for good. TickOnce records the matching minute whether or not the fire took — at-most-one-attempt per occurrence — so on an unauthorized deployment a nightly job spent its night on a refusal and never ran it, while the log blamed "chat busy or over cost cap". Last round I called this consistent with the cost cap and left it; the reviewer is right that it is the same data-loss shape already fixed for follow-ups in 70b3934, and that consistency with another lossy path is not a defence. schedule.Manager now takes the same pause predicate, wired to svc.RunsBlocked, and skips the whole tick before anything is marked — so the occurrence survives and fires once /login lands. Also from the review: - The adapters logged this refusal at Info while core/chat logs the identical state at Warn, so an operator filtering on Warn saw the background refusals and missed the user-facing ones — the very signal that the deployment needs /login now. All three are Warn. - The .env block promised "starts unauthorized and waits for /login" without naming the one case that still fails at startup: an empty CODEX_HOME, where there is nowhere to persist the login. Stated in both files. - The Telegram help test exercised only the with-login render, while every Claude, Codex-billing and access-token deployment gets the other one. Both now. golangci-lint: 0 issues. Build, vet, full suite and -race green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/telegram/.env.example | 2 + adapters/telegram/commands_test.go | 19 +++++--- .../roles/claude_tg_bot/templates/env.j2 | 2 + adapters/vk/receiver.go | 5 ++- adapters/vk/receiver_test.go | 2 +- cmd/duck-vk/main.go | 5 ++- cmd/flock-telegram/commands_test.go | 2 +- cmd/flock-telegram/main.go | 8 +++- core/schedule/manager.go | 23 ++++++++-- core/schedule/manager_test.go | 43 +++++++++++++++++-- 10 files changed, 91 insertions(+), 20 deletions(-) diff --git a/adapters/telegram/.env.example b/adapters/telegram/.env.example index e7e1454..b5e87f7 100644 --- a/adapters/telegram/.env.example +++ b/adapters/telegram/.env.example @@ -26,6 +26,8 @@ AI_BACKEND=claude # claude | codex | openai-compatible # code, so starting a sign-in is refused in group chats). Runs are blocked until # it completes. CODEX_REQUIRE_AUTH=false only removes the block — without # auth.json or CODEX_ACCESS_TOKEN runs then fail inside the Codex CLI instead. +# CODEX_HOME must stay non-empty: with an empty value startup still fails, since +# there would be nowhere to persist the login. # Full flow, and who ends up owning the Codex account: # docs/codex-integration-plan.md#subscription-setup CODEX_AUTH_MODE=subscription # subscription | billing diff --git a/adapters/telegram/commands_test.go b/adapters/telegram/commands_test.go index d885d72..ea73109 100644 --- a/adapters/telegram/commands_test.go +++ b/adapters/telegram/commands_test.go @@ -98,12 +98,19 @@ func TestStripCommandMention(t *testing.T) { // it can never submit a run); the constant IS the entire payload the handler // sends. func TestHelpTextListsCommands(t *testing.T) { - if HelpText(chat.WithLogin) == "" { - t.Fatal("HelpText(chat.WithLogin) is empty") - } - for _, cmd := range []string{"/help", "/new", "/stop"} { - if !strings.Contains(HelpText(chat.WithLogin), cmd) { - t.Fatalf("HelpText(chat.WithLogin) does not mention %q:\n%s", cmd, HelpText(chat.WithLogin)) + // Both renders: production picks between them with LoginVisibilityFor, and the + // WITHOUT-login one is what every Claude, Codex-billing and access-token + // deployment gets — so testing only the other would leave the common case + // uncovered. + for _, visibility := range []chat.LoginVisibility{chat.WithLogin, chat.WithoutLogin} { + got := HelpText(visibility) + if got == "" { + t.Fatalf("HelpText(%v) is empty", visibility) + } + for _, cmd := range []string{"/help", "/new", "/stop"} { + if !strings.Contains(got, cmd) { + t.Fatalf("HelpText(%v) does not mention %q:\n%s", visibility, cmd, got) + } } } } diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 b/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 index f956cd5..d8903a3 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 +++ b/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 @@ -34,6 +34,8 @@ CLAUDE_MAX_COST_PER_REQUEST={{ claude_max_cost_per_request }} # blocked until it lands. CODEX_REQUIRE_AUTH=false only removes the block — # without auth.json or CODEX_ACCESS_TOKEN runs then fail inside the Codex CLI # instead. +# CODEX_HOME must stay non-empty: with an empty value startup still fails, +# since there would be nowhere to persist the login. # Full flow, and who ends up owning the Codex account: # docs/codex-integration-plan.md#subscription-setup # billing: requires CODEX_API_KEY and CODEX_BILLING_ACK=true. diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index 17d172b..11e7847 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -345,7 +345,10 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { // its own refusals. if w.kind != workNone { if notice, blocked := r.auth.BlockedNotice(); blocked { - r.logger.Info("vk: codex unauthorized — refusing run", "peer_id", peerID, "user_id", msg.FromID) + // Warn, matching core/chat's identical refusal: an operator filtering on + // Warn would otherwise see the background refusals and miss the user-facing + // ones, which are the signal that the deployment needs /login right now. + r.logger.Warn("vk: codex unauthorized — refusing run", "peer_id", peerID, "user_id", msg.FromID) if r.authNotified.Should(chatIDStr(peerID)) { r.notify(ctx, peerID, notice) } diff --git a/adapters/vk/receiver_test.go b/adapters/vk/receiver_test.go index 86781e7..e597e0a 100644 --- a/adapters/vk/receiver_test.go +++ b/adapters/vk/receiver_test.go @@ -549,7 +549,7 @@ func TestReceiverScheduleEnabledReachesDispatch(t *testing.T) { if err != nil { t.Fatalf("open store: %v", err) } - mgr := schedule.NewManager(store, func(string, string, int64) bool { return true }, nil, nil, nil) + mgr := schedule.NewManager(store, func(string, string, int64) bool { return true }, nil, nil, nil, nil) r := NewReceiver(ReceiverConfig{ Service: svc, GroupID: 123, diff --git a/cmd/duck-vk/main.go b/cmd/duck-vk/main.go index b98349a..17a0b87 100644 --- a/cmd/duck-vk/main.go +++ b/cmd/duck-vk/main.go @@ -344,7 +344,10 @@ func buildScheduler(ctx context.Context, cfg config.Config, svc *chat.Service, l // accumulate detached goroutines or durable markers. The creator is re-validated // against the live VK allow-list at fire time (cfg.IsVKAllowed) so a de-listed // user's stored jobs stop running and are pruned. Mirrors cmd/flock-telegram. - mgr := schedule.NewManager(store, svc.InjectScheduled, cfg.IsVKAllowed, time.Now, logger) + // svc.RunsBlocked pauses the whole tick while the provider is unauthorized: + // TickOnce records a matching minute whether or not the fire took, so ticking + // into a refusal would spend the job's occurrence on nothing. + mgr := schedule.NewManager(store, svc.InjectScheduled, cfg.IsVKAllowed, svc.RunsBlocked, time.Now, logger) go func() { if err := mgr.Run(ctx); err != nil && ctx.Err() == nil { logger.Error("scheduler stopped", "error", err) diff --git a/cmd/flock-telegram/commands_test.go b/cmd/flock-telegram/commands_test.go index 030285e..54de99d 100644 --- a/cmd/flock-telegram/commands_test.go +++ b/cmd/flock-telegram/commands_test.go @@ -339,7 +339,7 @@ func TestScheduleCommandEnabledReachesDispatch(t *testing.T) { if err != nil { t.Fatalf("open store: %v", err) } - mgr := schedule.NewManager(store, func(string, string, int64) bool { return true }, nil, nil, nil) + mgr := schedule.NewManager(store, func(string, string, int64) bool { return true }, nil, nil, nil, nil) defaultHandler := func(ctx context.Context, b *bot.Bot, update *models.Update) { if msg := update.Message; msg != nil { diff --git a/cmd/flock-telegram/main.go b/cmd/flock-telegram/main.go index a737518..4f01609 100644 --- a/cmd/flock-telegram/main.go +++ b/cmd/flock-telegram/main.go @@ -557,7 +557,8 @@ func handleMessage(ctx context.Context, deps messageDeps, b *bot.Bot, msg *model // returns; without the limiter throttling it, the restraint belongs here. if strings.TrimSpace(cleaned) != "" || isVoice || isDocument || isPhoto { if notice, blocked := deps.auth.BlockedNotice(); blocked { - slog.Info("codex unauthorized — refusing run", "chat_id", msg.Chat.ID, "user_id", msg.From.ID) + // Warn, matching core/chat and the VK adapter: one state, one level. + slog.Warn("codex unauthorized — refusing run", "chat_id", msg.Chat.ID, "user_id", msg.From.ID) if deps.authNotified.Should(chatIDStr(msg.Chat.ID)) { sendCommandReply(ctx, b, msg.Chat.ID, notice) } @@ -1207,7 +1208,10 @@ func buildScheduler(ctx context.Context, cfg config.Config, svc *chat.Service, l // cron cannot accumulate detached goroutines or durable markers. The creator is // re-validated against the live allow-list at fire time (cfg.IsAllowed) so a // de-listed user's stored jobs stop running and are pruned. - mgr := schedule.NewManager(store, svc.InjectScheduled, cfg.IsAllowed, time.Now, logger) + // svc.RunsBlocked pauses the whole tick while the provider is unauthorized: + // TickOnce records a matching minute whether or not the fire took, so ticking + // into a refusal would spend the job's occurrence on nothing. + mgr := schedule.NewManager(store, svc.InjectScheduled, cfg.IsAllowed, svc.RunsBlocked, time.Now, logger) go func() { if err := mgr.Run(ctx); err != nil && ctx.Err() == nil { logger.Error("scheduler stopped", "error", err) diff --git a/core/schedule/manager.go b/core/schedule/manager.go index 97d2417..d6fd901 100644 --- a/core/schedule/manager.go +++ b/core/schedule/manager.go @@ -30,8 +30,11 @@ type Manager struct { store *Store fire func(chatID, prompt string, userID int64) bool allowed func(userID int64) bool - now func() time.Time - log *slog.Logger + // paused reports that firing is impossible right now, so the whole tick is + // skipped before any minute is recorded. Nil never pauses. + paused func() bool + now func() time.Time + log *slog.Logger } // NewManager builds a Manager. store is the durable job store; fire injects a @@ -41,13 +44,15 @@ type Manager struct { // cost cap, and TickOnce records the matching minute regardless (at-most-one-fire- // attempt per matching minute). allowed re-validates a job's creator against the // live allow-list at fire time, so a de-listed user's stored jobs stop running and -// are pruned; a nil allowed allows every creator (no fire-time check). now supplies +// are pruned; a nil allowed allows every creator (no fire-time check). paused +// skips an entire tick — see TickOnce — and a nil paused never pauses. now supplies // the current time (time.Now in production, a stub in tests); a nil log defaults to // slog.Default(). func NewManager( store *Store, fire func(chatID, prompt string, userID int64) bool, allowed func(userID int64) bool, + paused func() bool, now func() time.Time, log *slog.Logger, ) *Manager { @@ -57,7 +62,7 @@ func NewManager( if log == nil { log = slog.Default() } - return &Manager{store: store, fire: fire, allowed: allowed, now: now, log: log} + return &Manager{store: store, fire: fire, allowed: allowed, paused: paused, now: now, log: log} } // Run drives the scheduler until ctx is cancelled: every tick it reads the LIVE @@ -86,6 +91,16 @@ func (m *Manager) TickOnce() { if m.store == nil || m.fire == nil { return } + // Skipped WHOLE while firing is impossible (in production: the AI provider is + // unauthorized), before anything is marked. TickOnce records a matching minute + // regardless of whether the fire took, so ticking into a fire that must refuse + // would spend the job's occurrence on nothing: a nightly job would lose its + // night and never run it, and the log would blame a busy lane. Skipping leaves + // the minute unrecorded, so the job fires once the block clears. + if m.paused != nil && m.paused() { + m.log.Info("scheduler: paused; the provider is not authorized") + return + } now := m.now() locs := map[string]*time.Location{} // resolve each chat's zone once per tick for _, job := range m.store.ActiveJobs() { diff --git a/core/schedule/manager_test.go b/core/schedule/manager_test.go index 292fcf4..b352eef 100644 --- a/core/schedule/manager_test.go +++ b/core/schedule/manager_test.go @@ -45,7 +45,7 @@ func newManager(t *testing.T) (*schedule.Manager, *schedule.Store, *recordingFir rec := &recordingFire{} clock := time.Date(2026, 6, 18, 9, 0, 0, 0, time.UTC) nowPtr := &clock - mgr := schedule.NewManager(store, rec.fire, nil, func() time.Time { return *nowPtr }, nil) + mgr := schedule.NewManager(store, rec.fire, nil, nil, func() time.Time { return *nowPtr }, nil) return mgr, store, rec, nowPtr } @@ -57,7 +57,7 @@ func newDispatchManager(t *testing.T) (*schedule.Manager, *schedule.Store) { if err != nil { t.Fatalf("Open: %v", err) } - return schedule.NewManager(store, func(string, string, int64) bool { return true }, nil, nil, nil), store + return schedule.NewManager(store, func(string, string, int64) bool { return true }, nil, nil, nil, nil), store } // --- Dispatch tests --- @@ -433,7 +433,7 @@ func TestSchedulerPrunesJobOfDelistedCreator(t *testing.T) { // allowed denies the creator (id 42) of the job below. allowed := func(userID int64) bool { return userID != 42 } clock := time.Date(2026, 6, 18, 9, 0, 0, 0, time.UTC) - mgr := schedule.NewManager(store, rec.fire, allowed, func() time.Time { return clock }, nil) + mgr := schedule.NewManager(store, rec.fire, allowed, nil, func() time.Time { return clock }, nil) // A job whose schedule matches right now (every minute) but whose creator is // de-listed. @@ -470,7 +470,7 @@ func TestSchedulerSkipsBusyFireButMarksMinute(t *testing.T) { } clock := time.Date(2026, 6, 18, 9, 0, 0, 0, time.UTC) now := clock - mgr := schedule.NewManager(store, fire, nil, func() time.Time { return now }, nil) + mgr := schedule.NewManager(store, fire, nil, nil, func() time.Time { return now }, nil) if _, err := store.Add(schedule.Job{ ChatID: "100", Name: "j", Cron: "0 * * * *", Prompt: "go", CreatedBy: 7, Active: true, @@ -512,3 +512,38 @@ func TestRunStopsOnContextCancel(t *testing.T) { t.Fatal("Run did not return within 2s of cancel") } } + +// TestTickOnceSkippedWhilePaused is the lost-occurrence regression. TickOnce +// records a matching minute whether or not the fire took, so ticking into a fire +// that must refuse — the AI provider is unauthorized — would spend the job's +// occurrence on nothing: a nightly job would lose its night and never run it, +// while the log blamed a busy lane. Pausing has to skip the tick. +func TestTickOnceSkippedWhilePaused(t *testing.T) { + store := openStore(t) + if _, err := store.Add(schedule.Job{ + ChatID: "1", Name: "nightly", Cron: "* * * * *", Prompt: "run the nightly", CreatedBy: 7, Active: true, + }); err != nil { + t.Fatalf("add job: %v", err) + } + at := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) + + var fired int + paused := true + mgr := schedule.NewManager(store, func(string, string, int64) bool { + fired++ + return true + }, nil, func() bool { return paused }, func() time.Time { return at }, nil) + + mgr.TickOnce() + if fired != 0 { + t.Fatalf("fired %d times while paused, want 0", fired) + } + + // The minute must NOT have been recorded: the same occurrence still fires once + // the pause clears. + paused = false + mgr.TickOnce() + if fired != 1 { + t.Errorf("fired %d times after the pause cleared, want 1 — the occurrence was consumed", fired) + } +} From dffcb5078756068001574092201af0cbb238c47e Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 20:14:05 +0300 Subject: [PATCH 20/21] fix(codex): one "already told them" registry, owned by the state it describes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighteenth review round on #66. Three OnceNotifiers were live at once — one per adapter and one in core/chat — so the "told once per chat" invariant held inside each layer and failed across them: the poller's refusal explained itself to chat X, and X's next user message explained it again, word for word. My tests could not see it, because each exercises a single layer; the new one drives both against the same gate. The registry now belongs to codexauth.Manager, which owns the authorization state it describes. RunGate splits into Blocked() — side-effect free, for the follow-up sweeper and cron scheduler that only need to know — and NoticeFor(dest), which returns the explanation at most once per destination and clears every destination when the block lifts. core/chat.OnceNotifier is gone with the duplication that justified it. Also: the VK refusal notice went out synchronously from the poll loop on the raw context, and the VK client has no timeout of its own — a hung messages.send would have stopped updates in every chat, on exactly the broken deployment where this notice fires. It now uses the same bounded, detached send as the /login replies. golangci-lint: 0 issues. Build, vet, full suite and -race green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/vk/receiver.go | 20 +++++--- cmd/flock-telegram/main.go | 16 +++---- core/chat/oncenotice.go | 48 ------------------- core/chat/rungate.go | 27 +++++------ core/chat/rungate_test.go | 79 ++++++++++++++++++++++---------- core/chat/service.go | 47 +++++++++---------- core/codexauth/codexauth.go | 79 ++++++++++++++++++++++++++++---- core/codexauth/codexauth_test.go | 51 ++++++++++++++------- 8 files changed, 215 insertions(+), 152 deletions(-) delete mode 100644 core/chat/oncenotice.go diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index 11e7847..42b89ee 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -112,7 +112,6 @@ type Receiver struct { eventAck eventAckFunc sched *schedule.Manager auth *codexauth.Manager - authNotified *chat.OnceNotifier logger *slog.Logger } @@ -163,7 +162,6 @@ func NewReceiver(cfg ReceiverConfig) *Receiver { eventAck: cfg.EventAck, sched: cfg.Scheduler, auth: cfg.CodexAuth, - authNotified: chat.NewOnceNotifier(), logger: log, } } @@ -344,17 +342,27 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { // throttle means supplying the restraint here, exactly as core/chat does for // its own refusals. if w.kind != workNone { - if notice, blocked := r.auth.BlockedNotice(); blocked { + if r.auth.Blocked() { // Warn, matching core/chat's identical refusal: an operator filtering on // Warn would otherwise see the background refusals and miss the user-facing // ones, which are the signal that the deployment needs /login right now. r.logger.Warn("vk: codex unauthorized — refusing run", "peer_id", peerID, "user_id", msg.FromID) - if r.authNotified.Should(chatIDStr(peerID)) { - r.notify(ctx, peerID, notice) + // One registry, owned by the gate: this chat may already have been told by + // a refused background submission, and telling it again here would be the + // same sentence twice. + if notice := r.auth.NoticeFor(chatIDStr(peerID)); notice != "" { + // Bounded and detached: this runs INSIDE the poll loop, which handles + // updates serially, and the VK client has no timeout of its own — a hung + // messages.send would stop every chat. It fires on a broken deployment, + // where it would otherwise fire on every message. + sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexauth.NotifyTimeout) + r.notify(sendCtx, peerID, notice) + cancel() } return } - r.authNotified.Clear() + // Clears the gate's registry when authorization is back. + r.auth.NoticeFor(chatIDStr(peerID)) } if r.guards != nil { diff --git a/cmd/flock-telegram/main.go b/cmd/flock-telegram/main.go index 4f01609..57bb3f8 100644 --- a/cmd/flock-telegram/main.go +++ b/cmd/flock-telegram/main.go @@ -422,7 +422,6 @@ func textHandler( guards chat.GuardConfig, auth *codexauth.Manager, ) bot.HandlerFunc { - authNotified := chat.NewOnceNotifier() return func(ctx context.Context, b *bot.Bot, update *models.Update) { service := *svc if service == nil { @@ -435,7 +434,7 @@ func textHandler( if edited := update.EditedMessage; edited != nil { deps := messageDeps{ cfg: cfg, service: service, limiter: limiter, costs: costs, - guards: guards, auth: auth, authNotified: authNotified, + guards: guards, auth: auth, } handleMessage(ctx, deps, b, edited, true) return @@ -443,7 +442,7 @@ func textHandler( if msg := update.Message; msg != nil { deps := messageDeps{ cfg: cfg, service: service, vt: *vt, up: *up, - limiter: limiter, costs: costs, guards: guards, auth: auth, authNotified: authNotified, + limiter: limiter, costs: costs, guards: guards, auth: auth, } handleMessage(ctx, deps, b, msg, false) } @@ -483,8 +482,6 @@ type messageDeps struct { // auth blocks runs while the Codex backend has no completed sign-in. Nil (and // a nil *Manager) means "never blocks". auth *codexauth.Manager - // authNotified suppresses a repeat unauthorized notice per chat. - authNotified *chat.OnceNotifier } // handleMessage applies the allow-list and the group mention-gate to one message @@ -556,15 +553,18 @@ func handleMessage(ctx context.Context, deps messageDeps, b *bot.Bot, msg *model // work nobody asked for. The notice is sent once per chat until authorization // returns; without the limiter throttling it, the restraint belongs here. if strings.TrimSpace(cleaned) != "" || isVoice || isDocument || isPhoto { - if notice, blocked := deps.auth.BlockedNotice(); blocked { + if deps.auth.Blocked() { // Warn, matching core/chat and the VK adapter: one state, one level. slog.Warn("codex unauthorized — refusing run", "chat_id", msg.Chat.ID, "user_id", msg.From.ID) - if deps.authNotified.Should(chatIDStr(msg.Chat.ID)) { + // One registry, owned by the gate: this chat may already have been told by + // a refused background submission. + if notice := deps.auth.NoticeFor(chatIDStr(msg.Chat.ID)); notice != "" { sendCommandReply(ctx, b, msg.Chat.ID, notice) } return } - deps.authNotified.Clear() + // Clears the gate's registry when authorization is back. + deps.auth.NoticeFor(chatIDStr(msg.Chat.ID)) } if allow, reason := chat.CheckGuards(limiter, costs, guards, msg.From.ID); !allow { diff --git a/core/chat/oncenotice.go b/core/chat/oncenotice.go deleted file mode 100644 index 138351c..0000000 --- a/core/chat/oncenotice.go +++ /dev/null @@ -1,48 +0,0 @@ -package chat - -import "sync" - -// OnceNotifier tells each destination something at most once, until the -// condition that prompted it clears. -// -// It exists for notices that would otherwise repeat on every message while a -// deployment-wide condition holds — today, "the provider is not authorized". The -// first message from a chat earns an explanation; the next fifty do not, because -// the user has already been told and the answer has not changed. Clear resets -// every destination at once, so a condition that returns is announced afresh. -// -// The zero value is not usable; call NewOnceNotifier. A nil *OnceNotifier always -// reports true, so a caller that never wired one keeps its old behaviour. -type OnceNotifier struct { - mu sync.Mutex - told map[string]bool -} - -// NewOnceNotifier returns an empty notifier. -func NewOnceNotifier() *OnceNotifier { - return &OnceNotifier{told: map[string]bool{}} -} - -// Should reports whether id still needs to be told, marking it as told. -func (n *OnceNotifier) Should(id string) bool { - if n == nil { - return true - } - n.mu.Lock() - defer n.mu.Unlock() - if n.told[id] { - return false - } - n.told[id] = true - return true -} - -// Clear forgets every destination, so the next occurrence is announced again. -func (n *OnceNotifier) Clear() { - if n == nil { - return - } - n.mu.Lock() - defer n.mu.Unlock() - clear(n.told) -} diff --git a/core/chat/rungate.go b/core/chat/rungate.go index 6ea84e2..8d056cc 100644 --- a/core/chat/rungate.go +++ b/core/chat/rungate.go @@ -17,9 +17,14 @@ import "context" // Every one of those six calls blockSubmit; a seventh submit path must call it // too. TestGateBlocksEveryRunSource enumerates them. type RunGate interface { - // BlockedNotice returns the user-facing explanation and true when runs must - // be blocked, or ("", false) when they may proceed. - BlockedNotice() (string, bool) + // Blocked reports whether runs must be refused right now, without side effects. + Blocked() bool + // NoticeFor returns the explanation to send to dest, or "" when dest has + // already been told since the block last cleared. The registry belongs to the + // gate, not to its callers: the refusal reaches a chat from here AND from an + // adapter answering a user's message, and a registry per caller would let one + // chat be told twice. + NoticeFor(dest string) string } // RunsBlocked reports whether a run submitted right now would be refused. It @@ -30,8 +35,7 @@ func (s *Service) RunsBlocked() bool { if s.auth == nil { return false } - _, blocked := s.auth.BlockedNotice() - return blocked + return s.auth.Blocked() } // blockSubmit reports whether a submission must be refused because the provider @@ -57,19 +61,16 @@ func (s *Service) blockSubmit(ctx context.Context, chatID ChatID) bool { if s.auth == nil { return false } - notice, blocked := s.auth.BlockedNotice() - if !blocked { - // Every chat, not just this one: authorization is process-wide, so one chat - // submitting is proof the outage is over for all of them. Clearing only the - // submitting chat would leave a chat that stayed quiet through the recovery - // window silently un-notified when the next lapse hit it. - s.authNotified.Clear() + if !s.auth.Blocked() { + // NoticeFor clears the gate's registry when unblocked, so a chat that stayed + // quiet through the recovery still hears about the NEXT lapse. + s.auth.NoticeFor(chatID) return false } s.log.Warn("provider is not authorized — refusing run", "chat_id", chatID) - if first := s.authNotified.Should(chatID); first && notice != "" { + if notice := s.auth.NoticeFor(chatID); notice != "" { // Via notify, which bounds the send: this runs on the CALLER's goroutine, and // three of the six callers are loops that must not be stalled — the PR poller // dispatches inline, and the restart replay walks every stored marker. A diff --git a/core/chat/rungate_test.go b/core/chat/rungate_test.go index b974663..66a3207 100644 --- a/core/chat/rungate_test.go +++ b/core/chat/rungate_test.go @@ -4,6 +4,7 @@ package chat import ( "context" "log/slog" + "sync" "sync/atomic" "testing" "time" @@ -34,14 +35,48 @@ func (r *countingRunner) calls() int64 { return r.n.Load() } type stubGate struct { blocked atomic.Bool n atomic.Int64 + told stubTold } -func (g *stubGate) BlockedNotice() (string, bool) { +// stubTold mirrors the real gate's "told once per destination" registry. +type stubTold struct { + mu sync.Mutex + told map[string]bool +} + +func (s *stubTold) Should(dest string) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.told == nil { + s.told = map[string]bool{} + } + if s.told[dest] { + return false + } + s.told[dest] = true + return true +} + +func (s *stubTold) Clear() { + s.mu.Lock() + defer s.mu.Unlock() + clear(s.told) +} + +func (g *stubGate) Blocked() bool { g.n.Add(1) + return g.blocked.Load() +} + +func (g *stubGate) NoticeFor(dest string) string { if !g.blocked.Load() { - return "", false + g.told.Clear() + return "" } - return gateNotice, true + if !g.told.Should(dest) { + return "" + } + return gateNotice } func (g *stubGate) calls() int64 { return g.n.Load() } @@ -258,29 +293,23 @@ func TestNotifyResetIsProcessWide(t *testing.T) { waitUntil(t, func() bool { return notices(c) == 2 }) } -// TestOnceNotifier covers the shared restraint directly: told once per -// destination, everyone forgotten on Clear, and a nil notifier inert. -func TestOnceNotifier(t *testing.T) { - n := NewOnceNotifier() - - if !n.Should("a") { - t.Error("first ask for a = false, want true") - } - if n.Should("a") { - t.Error("second ask for a = true, want false") - } - if !n.Should("b") { - t.Error("first ask for b = false; destinations are independent") - } +// TestOneNoticeAcrossLayers is the composition regression the per-layer tests +// could not see. The refusal reaches a chat from TWO places — core/chat refusing +// a background submission, and an adapter answering a user's message — and each +// layer used to keep its own "already told them" registry. Each was correct +// alone; together they told one chat the same sentence twice, once for the +// poller's refusal and once for the user's next message. +func TestOneNoticeAcrossLayers(t *testing.T) { + c, gate := newFakeChat(), blockingGate() + s := gatedService(t, &countingRunner{}, c, gate, newFakePending()) - n.Clear() - if !n.Should("a") { - t.Error("after Clear, a was still marked as told") - } + // The background path refuses first and explains. + s.Inject(testChatID, "a reviewer commented") + waitUntil(t, func() bool { return notices(c) == 1 }) - var nilNotifier *OnceNotifier - if !nilNotifier.Should("a") { - t.Error("a nil notifier suppressed a notice; it must stay inert") + // The adapter path asks the SAME gate about the same chat, and must get nothing + // to send — this chat has already been told. + if notice := gate.NoticeFor(testChatID); notice != "" { + t.Errorf("the adapter would have repeated the notice: %q", notice) } - nilNotifier.Clear() } diff --git a/core/chat/service.go b/core/chat/service.go index 9a09b03..c0dc8b6 100644 --- a/core/chat/service.go +++ b/core/chat/service.go @@ -129,8 +129,6 @@ type Service struct { tick time.Duration nowFunc func() time.Time auth RunGate - // authNotified suppresses a repeat "provider unauthorized" notice per chat. - authNotified *OnceNotifier mu sync.Mutex // guards runChat, lastMsg, verifyRetry, budgetNotified and snapCache runChat map[string]ChatID // active runID -> chatID, for mapping Stop back to a chat @@ -210,29 +208,28 @@ func New(cfg Config) *Service { maxRunes = defaultMaxMessageRunes } return &Service{ - runner: cfg.Runner, - chat: cfg.Transport, - caps: caps, - retryAfter: cfg.RetryAfter, - maxRunes: maxRunes, - dispatch: cfg.Dispatcher, - workspace: cfg.Workspace, - sessions: cfg.Sessions, - pending: cfg.Pending, - costs: cfg.Costs, - costCapUSD: cfg.CostCapUSD, - outbox: cfg.Outbox, - nudge: newStarNudge(cfg.StarNudge, cfg.Transport, log), - postrun: cfg.PostRun, - opts: cfg.Opts, - timeout: cfg.Timeout, - log: log, - tick: tickInterval, - nowFunc: time.Now, - auth: cfg.Auth, - authNotified: NewOnceNotifier(), - runChat: map[string]ChatID{}, - lastMsg: map[ChatID]MessageID{}, + runner: cfg.Runner, + chat: cfg.Transport, + caps: caps, + retryAfter: cfg.RetryAfter, + maxRunes: maxRunes, + dispatch: cfg.Dispatcher, + workspace: cfg.Workspace, + sessions: cfg.Sessions, + pending: cfg.Pending, + costs: cfg.Costs, + costCapUSD: cfg.CostCapUSD, + outbox: cfg.Outbox, + nudge: newStarNudge(cfg.StarNudge, cfg.Transport, log), + postrun: cfg.PostRun, + opts: cfg.Opts, + timeout: cfg.Timeout, + log: log, + tick: tickInterval, + nowFunc: time.Now, + auth: cfg.Auth, + runChat: map[string]ChatID{}, + lastMsg: map[ChatID]MessageID{}, verifyRetry: map[ChatID][]string{}, budgetNotified: map[ChatID]string{}, diff --git a/core/codexauth/codexauth.go b/core/codexauth/codexauth.go index d81cb86..b3a4e75 100644 --- a/core/codexauth/codexauth.go +++ b/core/codexauth/codexauth.go @@ -129,6 +129,10 @@ func (c Config) logger() *slog.Logger { type Manager struct { cfg Config + // notified is the ONE "already told them" registry for the refusal notice — see + // NoticeFor. It has its own lock so a notice never contends with a login. + notified onceNotifier + mu sync.Mutex // cur is the attempt in flight, nil when idle. Holding the whole attempt behind // ONE pointer is what keeps the state machine honest: "is a login pending", @@ -158,7 +162,36 @@ type session struct { } // NewManager returns a Manager for cfg. -func NewManager(cfg Config) *Manager { return &Manager{cfg: cfg} } +func NewManager(cfg Config) *Manager { + return &Manager{cfg: cfg, notified: onceNotifier{told: map[string]bool{}}} +} + +// onceNotifier tells each destination something at most once, until Clear. +type onceNotifier struct { + mu sync.Mutex + told map[string]bool +} + +// Should reports whether dest still needs to be told, marking it as told. +func (n *onceNotifier) Should(dest string) bool { + n.mu.Lock() + defer n.mu.Unlock() + if n.told == nil { + n.told = map[string]bool{} + } + if n.told[dest] { + return false + } + n.told[dest] = true + return true +} + +// Clear forgets every destination, so the next occurrence is announced again. +func (n *onceNotifier) Clear() { + n.mu.Lock() + defer n.mu.Unlock() + clear(n.told) +} // Applicable reports whether an interactive login means anything on this // deployment: only the Codex backend in subscription mode has one. @@ -232,20 +265,46 @@ func (m *Manager) CredentialsPresent() bool { // NeedsLogin is the inverse of Authorized, for callers that read better that way. func (m *Manager) NeedsLogin() bool { return !m.Authorized() } -// BlockedNotice returns the reply to send instead of starting a run while Codex -// is unauthorized, and whether the run must be blocked at all. Blocking here — -// rather than letting the run fail deep inside the CLI — is what makes the -// unauthorized state recoverable: the user is told the one command that fixes it. -func (m *Manager) BlockedNotice() (string, bool) { - if m == nil || m.Authorized() { - return "", false +// Blocked reports whether runs must be refused right now. It has no side effects, +// so a caller that only needs to know — the follow-up sweeper and the cron +// scheduler, which skip their whole pass rather than consume work — can ask +// freely. +func (m *Manager) Blocked() bool { + if m == nil { + return false + } + return !m.Authorized() +} + +// NoticeFor returns the explanation to send to dest, or "" when dest has already +// been told since the last time the block cleared. +// +// The "told once" registry lives HERE, with the state it describes, because the +// refusal reaches a chat from two layers: an adapter answering a user's message, +// and core/chat refusing a background submission. A registry per layer keeps each +// layer's own repeats down but lets a chat be told twice — once by the poller's +// refusal, once by its own next message — and neither layer's tests can see it, +// because each exercises only itself. +func (m *Manager) NoticeFor(dest string) string { + if m == nil { + return "" + } + if !m.Blocked() { + // Authorization is deployment-wide, so its return clears every destination: + // a chat that stayed quiet through the recovery still hears about the NEXT + // lapse. + m.notified.Clear() + return "" + } + if !m.notified.Should(dest) { + return "" } // "in a direct message", because that is where the command will be accepted: - // this gate cannot see the destination, and sending the user to /login in a + // the caller cannot see the destination, and sending the user to /login in a // group would only earn them a second refusal — possibly in the only chat they // use with the bot. return "Codex is not authorized yet, so I can't run anything.\n\n" + - "Send /login in a direct message with me and I'll walk you through the one-time browser sign-in.", true + "Send /login in a direct message with me and I'll walk you through the one-time browser sign-in." } // NoLoginNeededText is the reply when this deployment has no interactive login diff --git a/core/codexauth/codexauth_test.go b/core/codexauth/codexauth_test.go index 8e3fd76..bd09f5f 100644 --- a/core/codexauth/codexauth_test.go +++ b/core/codexauth/codexauth_test.go @@ -148,23 +148,40 @@ func TestAuthorizedRechecksTheAuthFile(t *testing.T) { } } -// TestBlockedNotice: an unauthorized deployment blocks runs with a notice naming -// the one command that fixes it, and never blocks once authorized. -func TestBlockedNotice(t *testing.T) { +// TestBlockedAndNoticeFor: an unauthorized deployment refuses runs and explains +// itself ONCE per destination — the registry lives with the auth state, because +// the same refusal reaches a chat both from an adapter answering a user and from +// core/chat refusing a background submission. A registry per caller keeps each +// caller's repeats down and still tells one chat twice. +func TestBlockedAndNoticeFor(t *testing.T) { home := t.TempDir() m := NewManager(Config{Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: home}) - notice, blocked := m.BlockedNotice() - if !blocked { - t.Fatal("BlockedNotice() reported no block while unauthorized") + if !m.Blocked() { + t.Fatal("Blocked() = false while unauthorized") } - if !strings.Contains(notice, "/login") { + if notice := m.NoticeFor("chat-1"); !strings.Contains(notice, "/login") { t.Errorf("notice %q should tell the user to send /login", notice) } + if repeat := m.NoticeFor("chat-1"); repeat != "" { + t.Errorf("the same chat was told twice: %q", repeat) + } + if other := m.NoticeFor("chat-2"); other == "" { + t.Error("a different chat was silenced by the first one's notice") + } writeAuthFile(t, home) - if _, blocked := m.BlockedNotice(); blocked { - t.Error("BlockedNotice() still blocks after a completed login") + if m.Blocked() { + t.Error("Blocked() = true after a completed login") + } + // Asking while unblocked clears the registry, so a returning lapse is announced + // afresh — including to a chat that stayed quiet through the recovery. + m.NoticeFor("chat-1") + if err := os.Remove(filepath.Join(home, "auth.json")); err != nil { + t.Fatalf("remove auth.json: %v", err) + } + if again := m.NoticeFor("chat-1"); again == "" { + t.Error("a returning lapse was not announced") } } @@ -178,7 +195,7 @@ func TestNilManagerIsInert(t *testing.T) { if !m.Authorized() { t.Error("nil Manager reported unauthorized") } - if _, blocked := m.BlockedNotice(); blocked { + if m.Blocked() { t.Error("nil Manager blocked a run") } if m.Cancel() { @@ -221,7 +238,7 @@ func TestDispatchFullLoginPath(t *testing.T) { Env: []string{"PATH=" + os.Getenv("PATH")}, }) - if _, blocked := m.BlockedNotice(); !blocked { + if !m.Blocked() { t.Fatal("runs were not blocked before the login") } @@ -250,7 +267,7 @@ func TestDispatchFullLoginPath(t *testing.T) { if !strings.Contains(strings.ToLower(done), "authorized") { t.Errorf("final notice = %q, want a success confirmation", done) } - if _, blocked := m.BlockedNotice(); blocked { + if m.Blocked() { t.Error("runs are still blocked after a successful login") } } @@ -409,7 +426,7 @@ func TestRequireAuthFalseNeverBlocks(t *testing.T) { if !m.Authorized() { t.Error("Authorized() = false with CODEX_REQUIRE_AUTH=false") } - if _, blocked := m.BlockedNotice(); blocked { + if m.Blocked() { t.Error("runs were blocked with CODEX_REQUIRE_AUTH=false") } status := m.StatusText() @@ -649,7 +666,7 @@ func TestCleanExitWithoutACredentialIsNotSuccess(t *testing.T) { if !strings.Contains(got, home) { t.Errorf("final notice = %q, want it to name the directory that stayed empty", got) } - if _, blocked := m.BlockedNotice(); !blocked { + if !m.Blocked() { t.Error("runs were unblocked by a login that persisted nothing") } } @@ -949,10 +966,10 @@ func TestBlockedNoticePointsAtADirectMessage(t *testing.T) { m := NewManager(Config{ Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: t.TempDir(), }) - notice, blocked := m.BlockedNotice() - if !blocked { - t.Fatal("BlockedNotice reported no block while unauthorized") + if !m.Blocked() { + t.Fatal("Blocked() = false while unauthorized") } + notice := m.NoticeFor("dm") if !strings.Contains(notice, "direct message") { t.Errorf("notice = %q, want it to name where /login is accepted", notice) } From 15208536e6e1347530d2403f8c4dbc559f1a86e2 Mon Sep 17 00:00:00 2001 From: Korotkov Alex Date: Wed, 29 Jul 2026 20:28:11 +0300 Subject: [PATCH 21/21] fix(codex): keep the user's answer separate from the background one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nineteenth review round on #66. The MAJOR is the flip side of what I did last round, and my third pass over the same question — worth stating plainly. Round 16 gave each layer its own "told once" registry: correct per layer, and it told one chat the same sentence twice. Round 18 merged them into one: no more repeats, and now a refused BACKGROUND submission consumed the chat's only notice. The failure mode that creates is the target scenario of this whole PR: a container comes up with a lost auth.json, the restart replay spends the notice, and the person who writes next is met with silence — in a feature whose entire job is telling a human that /login is needed. Neither "one per layer" nor "one for everyone" was right. The registries are split by CHANNEL, both owned by codexauth.Manager: NoticeFor for a refused background submission, ReplyNoticeFor for the reply to a person's own message. They answer different questions — "has this chat been told the deployment is stuck" and "has this person been answered" — so neither can silence the other, and each still refuses to repeat itself. Also from the review: - Clearing the registry by calling NoticeFor and discarding the result was a side-effect-as-API, with a real race: if authorization lapsed between the Blocked() check and that call, it marked the destination as told and returned a text nobody sent — after which the run's own gate found the slot spent and the user heard nothing. NoticeReset is now explicit, in all three callers. - The VK refusal still sent synchronously inside the poll loop, on a 30s budget, with a client that has no timeout — the same stall /login was moved off the loop to avoid, on the same broken deployment, and worst case N chats × 30s of frozen updates including the /login that fixes it. Detached, like /login. golangci-lint: 0 issues. Build, vet, full suite and -race green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PG8hWLSWZHYXJKa1gtm4Rr --- adapters/vk/receiver.go | 24 ++++++---- adapters/vk/receiver_test.go | 41 ++++++++++++++-- cmd/flock-telegram/main.go | 9 ++-- core/chat/rungate.go | 20 ++++---- core/chat/rungate_test.go | 33 ++++++------- core/codexauth/codexauth.go | 82 +++++++++++++++++++++----------- core/codexauth/codexauth_test.go | 32 +++++++++++-- 7 files changed, 168 insertions(+), 73 deletions(-) diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index 42b89ee..abbe0cf 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -350,19 +350,23 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) { // One registry, owned by the gate: this chat may already have been told by // a refused background submission, and telling it again here would be the // same sentence twice. - if notice := r.auth.NoticeFor(chatIDStr(peerID)); notice != "" { - // Bounded and detached: this runs INSIDE the poll loop, which handles - // updates serially, and the VK client has no timeout of its own — a hung - // messages.send would stop every chat. It fires on a broken deployment, - // where it would otherwise fire on every message. - sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexauth.NotifyTimeout) - r.notify(sendCtx, peerID, notice) - cancel() + // ReplyNoticeFor, not NoticeFor: a refused background submission must not + // consume the answer this person is owed. + if notice := r.auth.ReplyNoticeFor(chatIDStr(peerID)); notice != "" { + // On its own goroutine, bounded, like /login and for the same reason: this + // is the poll loop, which handles updates serially, and the VK client has + // no timeout of its own. Waiting here would stall every chat — including + // the /login that fixes the state — on precisely the broken deployment + // where this fires. Nothing below waits for the reply. + go func() { + sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexauth.NotifyTimeout) + defer cancel() + r.notify(sendCtx, peerID, notice) + }() } return } - // Clears the gate's registry when authorization is back. - r.auth.NoticeFor(chatIDStr(peerID)) + r.auth.NoticeReset() } if r.guards != nil { diff --git a/adapters/vk/receiver_test.go b/adapters/vk/receiver_test.go index e597e0a..ad806f0 100644 --- a/adapters/vk/receiver_test.go +++ b/adapters/vk/receiver_test.go @@ -128,6 +128,18 @@ func (n *fakeNotice) seen() []string { return append([]string(nil), n.texts...) } +// waitUntilNotices waits for exactly want notices, tolerating the detached send. +func waitUntilNotices(t *testing.T, n *fakeNotice, want int) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) && len(n.seen()) < want { + time.Sleep(2 * time.Millisecond) + } + if got := n.seen(); len(got) != want { + t.Fatalf("got %d notices, want %d: %v", len(got), want, got) + } +} + // awaitOne waits for exactly ONE notice and fails if a second arrives. // // Returning on the FIRST notice would have made every "exactly one" assertion @@ -699,8 +711,8 @@ func TestReceiverBlocksRunsWhileCodexUnauthorized(t *testing.T) { if len(svc.handleCalls) != 0 { t.Errorf("an unauthorized Codex deploy started %d runs, want 0", len(svc.handleCalls)) } - if len(notices.texts) != 1 || !strings.Contains(notices.texts[0], "/login") { - t.Errorf("notices = %v, want one notice pointing at /login", notices.texts) + if got := notices.awaitOne(t); !strings.Contains(got[0], "/login") { + t.Errorf("notices = %v, want one notice pointing at /login", got) } } @@ -1034,7 +1046,28 @@ func TestUnauthorizedNoticeReturnsAfterAuthorization(t *testing.T) { t.Fatalf("remove auth.json: %v", err) } send() - if got := notices.seen(); len(got) != 2 { - t.Errorf("notices = %v, want the returning lapse announced again", got) + waitUntilNotices(t, notices, 2) +} + +// TestUserIsAnsweredEvenAfterABackgroundRefusal is the scenario this split +// exists for: a container comes up with a lost auth.json, the restart replay +// refuses and explains to the chat, and the person who then writes must STILL be +// answered. Sharing one registry between the two channels met them with silence, +// in the one feature whose whole job is telling a human that /login is needed. +func TestUserIsAnsweredEvenAfterABackgroundRefusal(t *testing.T) { + svc := &fakeService{} + notices := &fakeNotice{} + auth, _ := logintest.Unauthorized(t) + r := newTestReceiverWithAuth(svc, notices, auth) + + // A background submission is refused first and takes its own notice. + if background := auth.NoticeFor("42"); background == "" { + t.Fatal("the background channel said nothing while unauthorized") + } + + r.dispatch(context.Background(), msgNewUpdate(t, messageObject{FromID: 42, PeerID: 42, Text: "build it"})) + + if got := notices.awaitOne(t); !strings.Contains(got[0], "/login") { + t.Errorf("notices = %v, want the user answered despite the background refusal", got) } } diff --git a/cmd/flock-telegram/main.go b/cmd/flock-telegram/main.go index 57bb3f8..0fe49cb 100644 --- a/cmd/flock-telegram/main.go +++ b/cmd/flock-telegram/main.go @@ -556,15 +556,14 @@ func handleMessage(ctx context.Context, deps messageDeps, b *bot.Bot, msg *model if deps.auth.Blocked() { // Warn, matching core/chat and the VK adapter: one state, one level. slog.Warn("codex unauthorized — refusing run", "chat_id", msg.Chat.ID, "user_id", msg.From.ID) - // One registry, owned by the gate: this chat may already have been told by - // a refused background submission. - if notice := deps.auth.NoticeFor(chatIDStr(msg.Chat.ID)); notice != "" { + // ReplyNoticeFor, not NoticeFor: a refused background submission must not + // consume the answer this person is owed. + if notice := deps.auth.ReplyNoticeFor(chatIDStr(msg.Chat.ID)); notice != "" { sendCommandReply(ctx, b, msg.Chat.ID, notice) } return } - // Clears the gate's registry when authorization is back. - deps.auth.NoticeFor(chatIDStr(msg.Chat.ID)) + deps.auth.NoticeReset() } if allow, reason := chat.CheckGuards(limiter, costs, guards, msg.From.ID); !allow { diff --git a/core/chat/rungate.go b/core/chat/rungate.go index 8d056cc..2e21893 100644 --- a/core/chat/rungate.go +++ b/core/chat/rungate.go @@ -19,12 +19,16 @@ import "context" type RunGate interface { // Blocked reports whether runs must be refused right now, without side effects. Blocked() bool - // NoticeFor returns the explanation to send to dest, or "" when dest has - // already been told since the block last cleared. The registry belongs to the - // gate, not to its callers: the refusal reaches a chat from here AND from an - // adapter answering a user's message, and a registry per caller would let one - // chat be told twice. + // NoticeFor returns the explanation to send to dest after refusing a background + // submission, or "" when dest has already been told through that channel. The + // registry belongs to the gate, not to its callers, and the gate keeps a + // SEPARATE one for replies to a person's own message — so a refused poller + // relay cannot consume the answer a user is owed. NoticeFor(dest string) string + // NoticeReset forgets every destination once the block lifts, so the next lapse + // is announced afresh. Named rather than a side effect of NoticeFor, so no + // caller has to discard a text it never meant to send. + NoticeReset() } // RunsBlocked reports whether a run submitted right now would be refused. It @@ -62,9 +66,9 @@ func (s *Service) blockSubmit(ctx context.Context, chatID ChatID) bool { return false } if !s.auth.Blocked() { - // NoticeFor clears the gate's registry when unblocked, so a chat that stayed - // quiet through the recovery still hears about the NEXT lapse. - s.auth.NoticeFor(chatID) + // A chat that stayed quiet through the recovery still hears about the NEXT + // lapse. + s.auth.NoticeReset() return false } diff --git a/core/chat/rungate_test.go b/core/chat/rungate_test.go index 66a3207..a34b5f8 100644 --- a/core/chat/rungate_test.go +++ b/core/chat/rungate_test.go @@ -69,16 +69,19 @@ func (g *stubGate) Blocked() bool { } func (g *stubGate) NoticeFor(dest string) string { - if !g.blocked.Load() { - g.told.Clear() - return "" - } - if !g.told.Should(dest) { + if !g.blocked.Load() || !g.told.Should(dest) { return "" } return gateNotice } +func (g *stubGate) NoticeReset() { + if g.blocked.Load() { + return + } + g.told.Clear() +} + func (g *stubGate) calls() int64 { return g.n.Load() } // gateNotice is the text stubGate blocks with. @@ -293,23 +296,21 @@ func TestNotifyResetIsProcessWide(t *testing.T) { waitUntil(t, func() bool { return notices(c) == 2 }) } -// TestOneNoticeAcrossLayers is the composition regression the per-layer tests -// could not see. The refusal reaches a chat from TWO places — core/chat refusing -// a background submission, and an adapter answering a user's message — and each -// layer used to keep its own "already told them" registry. Each was correct -// alone; together they told one chat the same sentence twice, once for the -// poller's refusal and once for the user's next message. -func TestOneNoticeAcrossLayers(t *testing.T) { +// TestBackgroundNoticeDoesNotRepeatItself: the background channel explains once +// per chat, however many refused submissions follow. +func TestBackgroundNoticeDoesNotRepeatItself(t *testing.T) { c, gate := newFakeChat(), blockingGate() s := gatedService(t, &countingRunner{}, c, gate, newFakePending()) - // The background path refuses first and explains. s.Inject(testChatID, "a reviewer commented") waitUntil(t, func() bool { return notices(c) == 1 }) + s.Inject(testChatID, "another comment") + waitUntil(t, func() bool { return gate.calls() >= 2 }) - // The adapter path asks the SAME gate about the same chat, and must get nothing - // to send — this chat has already been told. + if got := notices(c); got != 1 { + t.Errorf("sent %d notices for two refused background submissions, want 1", got) + } if notice := gate.NoticeFor(testChatID); notice != "" { - t.Errorf("the adapter would have repeated the notice: %q", notice) + t.Errorf("the background channel would have repeated itself: %q", notice) } } diff --git a/core/codexauth/codexauth.go b/core/codexauth/codexauth.go index b3a4e75..fefdc07 100644 --- a/core/codexauth/codexauth.go +++ b/core/codexauth/codexauth.go @@ -129,9 +129,21 @@ func (c Config) logger() *slog.Logger { type Manager struct { cfg Config - // notified is the ONE "already told them" registry for the refusal notice — see - // NoticeFor. It has its own lock so a notice never contends with a login. - notified onceNotifier + // Two "already told them" registries for the refusal notice, one per channel it + // travels: notified for a refused BACKGROUND submission (a poller relay, a + // restart replay, a CI event) and notifiedUser for the reply to a person's own + // message. They are independent because they answer different questions — "has + // this chat been told the deployment is stuck" and "has this person been + // answered" — and sharing one cell means whichever fires first silences the + // other. A background refusal must never consume the answer a user is owed: + // this whole feature exists so a human learns that /login is needed. + // + // Both live here, with the state they describe, rather than one per caller — + // per-caller registries kept each caller's repeats down and still told one chat + // the same sentence twice. Each has its own lock so a notice never contends + // with a login. + notified onceNotifier + notifiedUser onceNotifier mu sync.Mutex // cur is the attempt in flight, nil when idle. Holding the whole attempt behind @@ -163,7 +175,11 @@ type session struct { // NewManager returns a Manager for cfg. func NewManager(cfg Config) *Manager { - return &Manager{cfg: cfg, notified: onceNotifier{told: map[string]bool{}}} + return &Manager{ + cfg: cfg, + notified: onceNotifier{told: map[string]bool{}}, + notifiedUser: onceNotifier{told: map[string]bool{}}, + } } // onceNotifier tells each destination something at most once, until Clear. @@ -276,37 +292,49 @@ func (m *Manager) Blocked() bool { return !m.Authorized() } -// NoticeFor returns the explanation to send to dest, or "" when dest has already -// been told since the last time the block cleared. -// -// The "told once" registry lives HERE, with the state it describes, because the -// refusal reaches a chat from two layers: an adapter answering a user's message, -// and core/chat refusing a background submission. A registry per layer keeps each -// layer's own repeats down but lets a chat be told twice — once by the poller's -// refusal, once by its own next message — and neither layer's tests can see it, -// because each exercises only itself. +// NoticeFor returns the explanation to send to dest after refusing a BACKGROUND +// submission, or "" when dest has already been told through that channel. func (m *Manager) NoticeFor(dest string) string { - if m == nil { + if m == nil || !m.Blocked() || !m.notified.Should(dest) { return "" } - if !m.Blocked() { - // Authorization is deployment-wide, so its return clears every destination: - // a chat that stayed quiet through the recovery still hears about the NEXT - // lapse. - m.notified.Clear() + return blockedNoticeText +} + +// ReplyNoticeFor returns the explanation to send to dest in reply to a PERSON's +// own message, or "" when that person has already been answered. +// +// Separate from NoticeFor on purpose: a refused poller relay or restart replay +// must not consume the answer a user is owed. Sharing one cell meant a container +// that came up with a lost auth.json replayed its markers, spent the chat's only +// notice on that, and then met the user with silence — in the one feature whose +// entire job is telling a human that /login is needed. +func (m *Manager) ReplyNoticeFor(dest string) string { + if m == nil || !m.Blocked() || !m.notifiedUser.Should(dest) { return "" } - if !m.notified.Should(dest) { - return "" + return blockedNoticeText +} + +// NoticeReset forgets every destination once authorization is back, so the NEXT +// lapse is announced afresh — including to a chat that stayed quiet through the +// recovery. It is a named operation rather than a side effect of asking for a +// notice, so no caller has to discard a text it never meant to send. +func (m *Manager) NoticeReset() { + if m == nil || m.Blocked() { + return } - // "in a direct message", because that is where the command will be accepted: - // the caller cannot see the destination, and sending the user to /login in a - // group would only earn them a second refusal — possibly in the only chat they - // use with the bot. - return "Codex is not authorized yet, so I can't run anything.\n\n" + - "Send /login in a direct message with me and I'll walk you through the one-time browser sign-in." + m.notified.Clear() + m.notifiedUser.Clear() } +// blockedNoticeText says what is wrong and where the fix is accepted. "In a +// direct message", because that is the only place /login can start: the callers +// cannot see the destination, and sending a user to /login in a group would only +// earn them a second refusal — possibly in the only chat they use with the bot. +const blockedNoticeText = "Codex is not authorized yet, so I can't run anything.\n\n" + + "Send /login in a direct message with me and I'll walk you through the one-time browser sign-in." + // NoLoginNeededText is the reply when this deployment has no interactive login // at all — including when no Manager was wired up. const NoLoginNeededText = "No interactive login is needed on this deployment: " + diff --git a/core/codexauth/codexauth_test.go b/core/codexauth/codexauth_test.go index bd09f5f..5989d3f 100644 --- a/core/codexauth/codexauth_test.go +++ b/core/codexauth/codexauth_test.go @@ -174,9 +174,10 @@ func TestBlockedAndNoticeFor(t *testing.T) { if m.Blocked() { t.Error("Blocked() = true after a completed login") } - // Asking while unblocked clears the registry, so a returning lapse is announced - // afresh — including to a chat that stayed quiet through the recovery. - m.NoticeFor("chat-1") + // NoticeReset (not a discarded NoticeFor) clears the registry, so a returning + // lapse is announced afresh — including to a chat that stayed quiet through the + // recovery. + m.NoticeReset() if err := os.Remove(filepath.Join(home, "auth.json")); err != nil { t.Fatalf("remove auth.json: %v", err) } @@ -1004,3 +1005,28 @@ func TestLoginAdvertisedNarrowerThanApplicable(t *testing.T) { t.Error("a nil Manager advertised /login") } } + +// TestBackgroundRefusalDoesNotConsumeTheUsersAnswer: the two channels keep +// independent registries. A container that comes up with a lost auth.json +// replays its markers, which refuses and explains — and the person who then +// writes to the bot must still be answered, because telling a human that /login +// is needed is the entire point of the feature. +func TestBackgroundRefusalDoesNotConsumeTheUsersAnswer(t *testing.T) { + m := NewManager(Config{ + Backend: BackendCodex, AuthMode: AuthSubscription, RequireAuth: true, Home: t.TempDir(), + }) + + if background := m.NoticeFor("chat-1"); background == "" { + t.Fatal("the background channel said nothing while unauthorized") + } + if reply := m.ReplyNoticeFor("chat-1"); reply == "" { + t.Error("a background refusal swallowed the answer owed to the user") + } + // Each channel still refuses to repeat itself. + if repeat := m.ReplyNoticeFor("chat-1"); repeat != "" { + t.Errorf("the user was answered twice: %q", repeat) + } + if repeat := m.NoticeFor("chat-1"); repeat != "" { + t.Errorf("the background channel repeated itself: %q", repeat) + } +}