Repository navigation
Make teams listen safe behind a relay - #119
Conversation
sionsmith
left a comment
There was a problem hiding this comment.
[P1] Persist accepted notifications before returning 202
src/listen/handler.rs returns 202 after Queue::try_push, but src/listen/queue.rs stores the only pending copy in an in-memory channel. This adds a crash-loss window for notifications the sender considers delivered. Graceful shutdown accounting cannot recover from SIGKILL, a process crash, or a host restart.
Reproduced on PR head 011fa39 using the real binary: start listen --port 0 --client-state review-secret with stdout piped and deliberately unread; POST six valid notification collections, each with a 512 KiB resourceData field. All six requests return 202. Kill the process with SIGKILL and drain stdout: zero complete NDJSON lines (only a partial first line), leaving all six acknowledged notifications unavailable. Restart has no spool to recover.
Perplexity cross-check and the primary Microsoft documentation confirm that Graph treats a 2xx response as delivered, and recommends validating and persisting notifications before returning 202. A relay following this PR's response table also stops retrying after 202.
Please retain accepted events durably before acknowledgement and recover/replay pending events after restart. Test a blocked stdout consumer followed by forced process death and restart. Duplicate delivery can still occur around the output/checkpoint boundary; document that separately. If the intended product is instead a lossy development stream, make the durability limitation an explicit design decision and revise the relay-safety claims before merging.
The existing formatting, check, Clippy and test suites pass locally and in Linux/macOS/Windows CI; those successful checks do not cover this crash case. Leaving this PR unmerged on this finding.
011fa39 to
a601160
Compare
Graph rejects Teams subscriptions longer than one hour unless the create request carries lifecycleNotificationUrl, and subscribe create had no way to set it, so any --expiration past one hour failed. Add --lifecycle-url, sent as lifecycleNotificationUrl and omitted when not given. The Subscription model now reads the field back so create output shows it. No client-side expiration limit: Graph's 4xx body already reaches the user verbatim through ApiError. Tests: serialization with and without the field, and a help test for the flag. Both failed before the change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hd5XXx9UkpLZpAAkvPPyUg
teams listen bound 0.0.0.0 and printed any POSTed JSON, so anyone who could reach the port could inject notifications. It had no body size or read deadline, unlimited connections, and wrote to stdout before answering, so a slow reader stalled Graph's request. Output dropped fields Graph sends (lifecycleEvent, subscriptionExpirationDateTime) but printed clientState. A port in use exited 3 as an auth error. - Bind 127.0.0.1 by default; --bind chooses another address, and --port 0 picks a free one. - --client-state / TEAMS_CLI_CLIENT_STATE drops notifications with another or no clientState (SHA-256 then constant-time compare), with a warning that names neither value. Dropped items still get 202: an error only makes Graph resend them. Without it, warn at startup. - 1 MiB body cap (413), 10s header and body deadlines (408), at most 64 connections; more wait in the backlog. - Output goes through an 8 MiB queue to a writer thread. A batch is queued whole and answered 202, or 503 when full. Validation never touches the queue. Ctrl+C waits up to 2s for queued output. - Keep unknown fields via serde flatten, add kind (lifecycle/change), strip clientState. - Bind failure is ConfigError (exit 10) naming the address. - README "Running behind a relay", man page, changelog. Tests: unit tests for the handler, queue and model; tests/listen.rs runs the binary on 127.0.0.1:0 over real TCP: forged/missing clientState not printed, lifecycle kinds, 5 MiB body 413 then alive, stalled connections not blocking requests, a stalled stdout giving 503 while validation still works, port in use exiting 10. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hd5XXx9UkpLZpAAkvPPyUg
A notification answered 202 could still be waiting in the stdout queue. On Ctrl+C the listener waited 2s, ignored whether the queue had drained, and exited 0, so with a slow reader acknowledged notifications were lost silently; handlers could also keep enqueueing meanwhile. A closed stdout exited 0 from the writer thread, losing the rest too. On Ctrl+C or SIGTERM the listener now stops accepting connections and closes queue admission under the queue's lock, so handlers past admission finish and later ones answer 503. It then waits for the writer with no time limit. A second signal abandons the queue. The writer counts lines written; if any acknowledged line is not written (second signal, or a stdout write error, which also closes admission), the count goes to stderr and the process exits 1 directly. Exit 1 (general) because no documented category fits a local output failure, and the JSON error envelope would go to the stdout that failed. Tests: queue unit tests for closed admission, full drain, aborted drain and stdout failure counts; a handler test for 503 while shutting down. Unix-only binary tests: SIGINT with a stalled reader, then reading prints every acknowledged notification and exits 0; SIGINT then SIGTERM exits 1 with the lost count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hd5XXx9UkpLZpAAkvPPyUg
Logging coloured stderr unless --no-color was given, so a redirected log, `teams listen 2>listen.log` above all, filled with ANSI escape codes. Colour now also requires stderr to be a terminal and NO_COLOR to be unset or empty (no-color.org); --no-color still turns it off. Tests: the rule as a unit test; the listen test that reads a dropped notification's warning from piped stderr now finds no escape codes, which fails against the previous setting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hd5XXx9UkpLZpAAkvPPyUg
Graph validates lifecycleNotificationUrl as well as notificationUrl when a subscription is created, and the README suggests https://your-endpoint/lifecycle. The listener answered that path 404, so following the README failed subscription creation. Answer validation and accept notifications on /lifecycle like / and /webhook, with a unit and a black-box test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
client_state_filters_forged_notifications failed three times locally, under heavy concurrent load, with ENOTCONN on the first write after a successful connect. About 650 stress runs and 300 instant-POST startups did not reproduce it, so the panic now names the port and carries the listener's stderr, so a recurrence shows what the listener was doing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a601160 to
770b375
Compare
|
Thanks @aberoham, this is thorough work. Here's what I verified and what I added. Reproduced on main by running the real binary against curl:
Verified on this branch. All of those are fixed:
The response codes match Microsoft's webhook guidance:
Found and fixed ( Rebased onto main (after #124, #125, #128 and #129), merging the CHANGELOG sections. Test flake, not reproduced. fmt, clippy |
…ommands Bring the skills up to date with osodevops#119 and osodevops#125: - automation: listen binds 127.0.0.1, takes the subscription's clientState through TEAMS_CLI_CLIENT_STATE, and answers /lifecycle; subscribe create gets --lifecycle-url. The lifetimes now match Graph's table (Teams messages and chats up to three days, presence one hour, 45 minutes minimum, lifecycle URL over an hour), replacing "at most one hour", and say what to do on each lifecycle event. - setup: --redirect-uri for a bring-your-own app's browser login, what is saved to the profile, and what AADSTS50011, a "Sign-in failed" error and the 10-minute wait mean. agent_skill_commands_parse runs every teams line in the skills' code blocks through Cli::try_parse_from, so a renamed flag or a missing argument fails the build instead of teaching agents a broken command. AGENTS.md lists skills/ and adds it to the keep-in-sync rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(skills): add agent skills for setup, messaging, admin and automation Four installable SKILL.md files under skills/, following the osodevops skills convention (one focused skill per workflow surface, each with agents/openai.yaml): - teams-cli-setup: install, delegated sign-in, profiles, CI auth, the JSON envelope, exit codes and pagination - teams-cli-messaging: find ids, read, send, reply, react, delete and restore, attachments and inline images, channel files, chats - teams-cli-admin: teams, channels, members, tags, apps and tabs, with a confirmation procedure for destructive commands - teams-cli-automation: presence, meetings, activity notifications, change-notification subscriptions, the local listener, and polling Every command line in the skills was checked against the binary's --help output. README gets an "Agent Skills" section with the npx skills add install command; the docs index and CHANGELOG link to the skills, and the attachments spec names the real skill file. * feat(skills): cover listen hardening and --redirect-uri; test skill commands Bring the skills up to date with #119 and #125: - automation: listen binds 127.0.0.1, takes the subscription's clientState through TEAMS_CLI_CLIENT_STATE, and answers /lifecycle; subscribe create gets --lifecycle-url. The lifetimes now match Graph's table (Teams messages and chats up to three days, presence one hour, 45 minutes minimum, lifecycle URL over an hour), replacing "at most one hour", and say what to do on each lifecycle event. - setup: --redirect-uri for a bring-your-own app's browser login, what is saved to the profile, and what AADSTS50011, a "Sign-in failed" error and the 10-minute wait mean. agent_skill_commands_parse runs every teams line in the skills' code blocks through Cli::try_parse_from, so a renamed flag or a missing argument fails the build instead of teaching agents a broken command. AGENTS.md lists skills/ and adds it to the keep-in-sync rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Sion Smith <sion.smith@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
teams listenprinted every notification posted to it without checkingclientState, listened on every interface, had no size or time limits, and held Graph's response until stdout took the line. This makes it safe to run behind a tunnel or relay.Behaviour changes:
127.0.0.1. Use--bind 0.0.0.0to accept other hosts.--client-state(orTEAMS_CLI_CLIENT_STATE) drops notifications that don't match. Without it, startup warns that notifications are unauthenticated.kind(changeorlifecycle), and no longer includeclientState.CONFIG_ERROR(exit 10), notAUTH_FAILED(exit 3).NO_COLORis unset or empty.subscribe create --lifecycle-urlsetslifecycleNotificationUrl, which Graph requires for Teams subscriptions over an hour.Review guide:
cbbf73a--lifecycle-url.c95b22cthe listener. The parts worth most attention are the filtering and response codes insrc/listen/handler.rs.decc906shutdown. Check the acknowledgement accounting insrc/listen/queue.rs.011fa39log colour.Testing:
tests/listen.rsruns the real binary over sockets, including a stdout reader that stops reading (503, validation still answers) and SIGINT with a backlog. Also run locally against curl.https://claude.ai/code/session_01Hd5XXx9UkpLZpAAkvPPyUg