Skip to content

feat(meetings): connect a calendar's credentials and OAuth from Settings - #8081

Open
kaizawa97 wants to merge 2 commits into
kirodotdev:mainfrom
kaizawa97:pr/meetings-calendar-settings-ui
Open

feat(meetings): connect a calendar's credentials and OAuth from Settings#8081
kaizawa97 wants to merge 2 commits into
kirodotdev:mainfrom
kaizawa97:pr/meetings-calendar-settings-ui

Conversation

@kaizawa97

@kaizawa97 kaizawa97 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #2190 (pr/meetings-calendar-providers); this PR's own change is
the single commit on top of it. Rebase onto main once #2190 lands.

Problem / Motivation

#2190 adds the CalDAV, Google Calendar and Microsoft 365 providers and the
routes to store their credentials and run the OAuth handshake, but no screen
calls them. Settings → Calendar shows a provider picker and a source field and
nothing else, so the three new providers can only be connected by hand-crafting
PUT /calendar/credentials and POST /calendar/oauth/start requests.

Why it matters

A provider nobody can configure from the product is not shipped. Until this
lands, the calendar picker offers three choices that lead nowhere for anyone
who is not comfortable with curl, and the security work in #2190 (write-only
store, OAuth with PKCE) protects credentials that no user can enter.

What changed (motivation → approach → change)

Goal. Let a user connect CalDAV, Google or Microsoft 365 from Settings
without the page ever seeing a stored value and without a second, hand-kept
list of what each provider needs.

Approach. The form's shape comes from the backend, not the frontend. #2190's
write path already owns two tables — the per-provider field allowlist
(_CREDENTIAL_FIELDS) and the OAuth client map (_OAUTH_CLIENTS). Exposing them
in the GET is what keeps the fields a user sees and the fields a PUT accepts from
ever disagreeing, and keeps an out-of-repo provider able to describe its own
form. Rendering reuses the dashboard's existing write-only SecretField, so a
stored secret gets the same mask / Replace / Remove treatment as a Slack token.

Change.

  • Backend (small): GET /calendar/credentials gains providers, a
    {provider: {fields, oauth}} map derived from those two tables. Pinned by a
    test that asserts the map equals the allowlist and omits ics.
  • website/src/apps/meetings/components/CalendarCredentials.tsx (new). Under the
    provider picker, for the active provider: a status badge (Connected /
    Credentials saved / Not connected), one SecretField per field, Save
    credentials
    , Sign in with {provider} for OAuth providers, Disconnect
    once anything is stored. Renders nothing for a provider the schema does not
    list (none, ics).
    • Write-only: a typed value is sent as a string, a removed field as null, an
      untouched field is not sent at all — so "leave it alone to keep it" holds
      without the page ever knowing the value.
    • "Connected" is derived from field names: a refresh_token for OAuth, every
      listed field for a password provider. The status query has staleTime: 0
      and refetches on window focus because the consent finishes in another tab.
    • Sign-in is disabled (with a hint) until a client_id is stored. It POSTs
      oauth/start and opens the consent URL with
      window.open(url, '_blank', 'noopener,noreferrer') — the Electron shell
      forwards that to the OS browser — and, when a popup blocker answers null,
      offers the same URL as a link.
  • SettingsView.tsx mounts the component inside the Calendar card; api.ts
    gains the four client methods and the wire types.
  • 21 strings under apps.meetings.settings.* in all 12 locales, translator
    context for the five ambiguous ones, pseudolocale regenerated.
  • docs/system-specs/modules/meetings.md: the five feat(meetings): read the user's calendar over CalDAV, Google and Microsoft 365 #2190 routes are added to
    the route table (they were missing), plus a "Credentials in Settings" section
    and the new component in the layout and tests lists.

Tests

  • website/src/test/MeetingsCalendarCredentials.test.tsx (13): renders nothing
    for a schema-less provider; renders the backend's fields and Not connected;
    saves only what was typed and never echoes a value into the DOM; sends null
    for a removed field; keeps the draft on a failed save; disconnects through the
    forget route; keeps sign-in disabled without a client id; opens the consent URL
    via window.open with noopener,noreferrer; falls back to a link when the
    popup is blocked; reports a refused sign-in without opening anything; shows
    Connected once a refresh token is stored; renders nothing when the status
    cannot be read. isConnected is unit-tested on its own.
  • MeetingsSettingsViewCoverage.test.tsx gains a resolved credentials mock.
  • Backend: test_get_describes_each_providers_form_from_the_allowlist in
    test_meetings_calendar_routes.py.

Component coverage 96.9%. Local: tsc -b, eslint, vitest (110 across the four
touched suites incl. catalog parity and dead keys), npm run i18n:check (all
rows ok), focus-cue and phantom-class gates, 47 backend route tests, flake8,
baselined black, docs-lint.

Manual verification

Screenshots below were taken against a gateway built from this branch with an
isolated KIROCREW_HOME, driving the real routes: CalDAV credentials saved
through the UI flip the badge to Connected and mask both fields; a Google client
id saved through the UI enables Sign in. The OAuth consent itself was not
completed against a live Google or Microsoft tenant (no registered client here);
the handshake is covered by #2190's tests, and this PR asserts its side at the
window.open boundary.

Screenshots / video

CalDAV, nothing stored:

CalDAV not connected

CalDAV after saving a username and password — masked, Connected, Disconnect
available:

CalDAV connected

Google Calendar with a client id stored — sign-in enabled:

Google ready to sign in

More

Google before a client id is stored (sign-in disabled, hint shown):

Google needs client id

Full settings page:

Settings page

Related Issues

Depends on #2190. The poller PR (pr/meetings-calendar-poller) is independent.

Checklist

@kaizawa97
kaizawa97 requested a review from a team September 3, 2026 04:48
@kaizawa97
kaizawa97 requested a review from a team as a code owner September 3, 2026 04:48
@kaizawa97
kaizawa97 requested a review from pepmach September 3, 2026 04:48
@github-actions github-actions Bot added fork Pull request from a fork (external contributor) readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 3, 2026
@kaizawa97
kaizawa97 force-pushed the pr/meetings-calendar-settings-ui branch from 7af15db to 4530692 Compare September 3, 2026 05:28
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 3, 2026
@github-actions github-actions Bot added the merge conflict Branch has merge conflicts with its base — author must resolve before merge label Sep 3, 2026
@bolichen97

Copy link
Copy Markdown
Collaborator

Open PR relationship audit

This is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion.

Relationship findings

  • PR #2190 is OVERLAPPING relative to this PR. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #2190: REBASE. 8081 contains this PR verbatim, but as the dependent half of an author-declared stack: 2190 is the base. Closing 2190 in favour of 8081 would only be right if the two are collapsed into one PR — and 8081 carries the same routes/calendar.py conflict with merged PR #8080, so collapsing removes no rebase work. Keep 2190 as the landing unit and let 8081 rebase onto main afterwards. Files: src/kiro_crew/apps/builtins/meetings/backend/credentials.py, src/kiro_crew/apps/builtins/meetings/backend/routes/calendar.py. The two independent directions used different labels; the matrix conservatively retains OVERLAPPING for coordination.
  • This PR is OVERLAPPING with PR #7194. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #8081: REBASE. Same app and the same shared append points, entirely different user goal. Independent; no coordination needed beyond ordinary locale-file merges. Files: website/src/apps/meetings/api.ts and src/kiro_crew/apps/builtins/meetings/backend/constants.py. The two independent directions used different labels; the matrix conservatively retains OVERLAPPING for coordination.
  • This PR is OVERLAPPING with PR #8080. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #8081: REBASE. PR #8080 landed on 2026-09-03T09:27:25Z, after this branch was cut, and is what produced the 'merge conflict' label and the PR #8080 cross-reference on this PR's timeline. Read-only git merge-tree against live origin/main confirms exactly two content conflicts (docs/system-specs/modules/meetings.md and backend/routes/calendar.py); everything else auto-merges. Nothing in PR #8080 implements any part of PR #8081. Files: src/kiro_crew/apps/builtins/meetings/backend/routes/calendar.py, docs/system-specs/modules/meetings.md, src/kiro_crew/apps/builtins/meetings/backend/constants.py, routes/__init__.py, website/src/apps/meetings/api.ts, test/test_meetings_routes.py.

No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit.

@dwu96 dwu96 added the needs-pr-triage PR scanner: awaiting automated triage label Sep 7, 2026
@NicholasRBowers NicholasRBowers added needs-author-decision PR blocked on author input and removed needs-pr-triage PR scanner: awaiting automated triage labels Sep 7, 2026
@NicholasRBowers

Copy link
Copy Markdown
Contributor

🤖 Kiro Crew [operator: NicholasRBowers#a942f9ca]: This PR has been inactive for 7+ days. I reviewed the blockers but they require your input:

When you've addressed these, the pipeline will re-assess on its next cycle.

kaizawa97 and others added 2 commits September 8, 2026 17:20
…osoft 365

Meetings had to be created by hand, so the app never knew what was already
scheduled. This adds calendar providers: CalDAV, Google Calendar and
Microsoft 365, behind one interface, plus the credential store and OAuth
handshake they need.

Security decisions worth naming, because they shaped the code:

Credentials live under `<crew-home>/workspace/meetings/`, not
`app_data_dir("meetings")`. The latter is a region `store.contain` opens to
agent-supplied paths, and a calendar refresh token is not something to keep
where an agent can ask for a file.

The address vet is `link_unfurl.vet_unfurl_url`, reused rather than
reimplemented. This started as a purpose-built check in `calendar.py` and that
was a mistake: a second copy of this logic is a second place to fix a bypass,
and the copy was already weaker in four ways the shared one covers.

    0177.0.0.1        approved, then fetched at 177.0.0.1 — `ipaddress` will
                      not read the octal, so it fell through to DNS and
                      getaddrinfo read `0177` as decimal. The vet and the
                      connection disagreed about the target, which is the exact
                      class of bug the pinning exists to prevent.
    100.64.0.0/10     approved. `is_private` does not cover CGNAT; only
                      `is_global` does. On a machine on a tailnet, that range
                      IS the private network.
    fec0::/10         approved. Deprecated IPv6 site-local reports is_global.
    .local / .onion   approved. Resolve through a side channel, or not at all.

`link_unfurl` also owns `test_vet_rejects_every_special_purpose_range`, which
pins the refusal set against a table of IANA special-purpose prefixes — so the
next gap is found by the suite instead of by a reviewer. A second
implementation here would not have inherited that.

One refusal went the other way and is kept, layered on top: a resolved address
that `ipaddress` cannot read is refused, not skipped.
`_reject_if_internal_ip` returns silently for a non-literal, because to it a
non-literal is a hostname still to be resolved; here the list is already a
resolution result, so an unreadable entry would reach the pin unchecked.

The pin, the redirect hop loop and the same-origin check stay local. They are
not duplicated — `VettedUrl`'s own docstring says to pin the resolver on
`wire_host`, so this is the caller side of that contract. `resolve` is injected
only to keep every address the vet approved, since `VettedUrl` reports one and
a multi-homed calendar host should keep its fallbacks.

Ports narrow to 80/443, which https-only leaves as 443. That is stricter than
"whatever port the URL names", deliberately: a calendar on another port is
nearly always an internal service, and the port is the cheapest place to stop
this endpoint being used to probe for one. Nothing is broken by starting
strict — this feature has never shipped — and relaxing it later is one line.

XML parsing rejects a DOCTYPE outright. Measured, not assumed: the stdlib
ElementTree expands internal entities while refusing external ones, and
`XMLParser.doctype` is ignored on 3.12, so the rejection is done in
`TreeBuilder.doctype` where it is actually reached. `defusedxml` would be the
obvious answer but `test_pip_deps_consistency.py` pins the extras, so adding
a dependency is a wider change than this needs.

Redaction is applied once, in `build_event()`, rather than per provider — a
new provider then cannot forget it, and `security_posture.py` gains one sink
instead of three.

The OAuth redirect URI is derived from the request's own origin. Dashboard
auth is an HMAC-signed cookie scoped to the host, and the port is
configurable, so a constant would break on any non-default port and
`localhost` vs `127.0.0.1` would not share the cookie.

Drive-to-green amendments by Kiro Crew (original work by kaizawa97):
rebased onto current main; SEL-audit the invalid/expired/forged-state
rejection in the OAuth callback so the anti-forgery refusal is visible
in the audit trail, with a test locking the record in.

Coverage Gate: added route-level tests for the credential GET/PUT/forget
surface, the OAuth start route, the callback success path, and the
credential store's failure-path contracts (atomic-write cleanup,
owner-lockdown degradation, empty-provider refusal), lifting
routes/calendar.py 56%->95% and credentials.py 77%->100% past the 80%
per-file floor without touching the baseline.

Review round 2: the credential store now writes through the repo's
canonical atomic_write helper (restrict_to_owner=True, fail-closed) —
the temp is locked to the owner BEFORE content lands and a lockdown
failure refuses the write instead of publishing tokens under an
inherited Windows ACL, closing a GPT blocking finding and deleting the
hand-rolled temp+fsync+replace copy First Principles flagged.

Review round 3: event ids are derived from the ORIGINAL provider uid,
not the redacted form. Microsoft Graph ids are long base64 blobs that
trip the credential redactor's entropy heuristic, so different events
collapsed to one [REDACTED] placeholder, one shared digest, one shared
meeting directory. Redaction still applies to every displayed field;
_event_id_for makes the raw uid filesystem-safe itself.

Review round 4: (1) schema-invalid credential stores now raise
_StoreUnreadable on every invalid root/entry/value instead of silently
dropping entries — a partial view must never be rewritten back, the
same contract the parse-failure path already had. (2) A uid the
credential redactor flags switches to a digest-only event-id source, so
a credential-shaped uid never surfaces a readable stem in the
agent-visible id (while staying unique per uid and stable across
syncs). (3) https-only now implies port 443 at the vet boundary, keyed
on the scheme allow-list so the real-server rebinding tests keep
working.

Review round 5: hoisted the oauth import in routes/calendar.py to
module scope (no circular dependency exists; oauth imports
providers.calendar, nothing imports back into routes). The symlink
finding on the credential directory is rebutted on the PR with
reachability evidence; class-level parent-symlink refusal belongs in
the shared atomic_write helper and is tracked as a follow-up issue.

Rebased onto main to absorb the new baselined black gate; ran
black --target-version py310 on the three in-scope offenders.

Rebased onto current main (1600+ commits), reconciling with the meetings
fixes that landed there since this branch's last rebase:

* kirodotdev#4380 (all-day rendering): the all_day flag is threaded through this
  PR's build_event funnel so .ics all-day events keep main's date-only
  semantics; providers still default to timed events.
* kirodotdev#5557 (Windows TZID -> IANA): _graph_when now consults the same
  _WINDOWS_TO_IANA table before falling back to UTC, closing the
  "Tokyo Standard Time read as UTC" gap for Graph responses that ignore
  the Prefer header. Regression tests updated to pin the mapping and the
  unknown-zone fallback separately.
* The workspace/meetings credential-store entry made the variable-leaf
  gate fence the whole crew workspace (breaking main's pinned
  `cat ~/.kiro/crew/workspace/$PROJ/notes.md`); the workspace parent is
  now in _GENERAL_PURPOSE_PARENT_DIRS per the .config/gcloud precedent,
  with the meetings child still fully fenced by is_sensitive_path.

The setup.cfg defusedxml hunk is dropped: main now declares defusedxml
in install_requires itself, so the hunk had become a duplicate
declaration with a conflicting ceiling.

Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
The calendar providers that need a credential (CalDAV, Google Calendar,
Microsoft 365) could only be connected by calling the credentials and
OAuth routes by hand: Settings -> Calendar showed a provider picker and a
source field and nothing else.

Settings -> Calendar now renders the credential form for the active
provider under the picker. The form's shape comes from the backend: GET
/calendar/credentials gains a `providers` map built from the same
allowlist and OAuth table the PUT enforces, so the fields shown are the
fields that can be written and a provider that takes none renders
nothing. Every field is write-only (a stored value never reaches the
browser): a set field shows a mask with Replace / Remove, a typed value
is sent as a string, a removed one as null, an untouched one not at all.
"Connected" is derived from field names -- a refresh token for OAuth,
every field for a password provider -- and re-reads on window focus,
because the OAuth consent finishes in another tab. Sign-in stays
disabled until a client id is stored; it opens the consent URL in a new
tab and offers the same URL as a link when a popup blocker refuses.
Disconnect drops the provider's credentials and any pending flow.

Strings land in all twelve locales; the pseudolocale is regenerated.
@bolichen97
bolichen97 force-pushed the pr/meetings-calendar-settings-ui branch from 4530692 to c53d1a2 Compare September 8, 2026 17:29
@bolichen97

Copy link
Copy Markdown
Collaborator

Rebased onto main 8534cbf by a maintainer as part of the 2026-09-08 open-PR audit. Four conflicts:

  • routes/__init__.py: kept main's one-line /calendar/providers registration, added the 5 new credential/OAuth routes above it.
  • routes/calendar.py: import-list only, took the added BadRequest, audit.
  • security.py: gone (split into security/), so "workspace/meetings" moved verbatim into security/paths.py's _CREW_SECRET_LEAVES. The _GENERAL_PURPOSE_PARENT_DIRS hunk was dropped: that set and the cd-taint mechanism it excluded from no longer exist on main.
  • docs/system-specs/modules/meetings.md: kept both new sections.

Main's new test_sandbox_governance_mask.py requires every crew secret leaf to have a sandbox disposition, so "workspace/meetings" was also added to sandbox._CREW_HIDDEN_LEAVES (no carve-out needed, the backend runs in-process).

Ran locally: black/isort/flake8 on changed files, the 4 meetings test files (509 passed), all test_sandbox_* (1400 passed), tsc --noEmit, and the 2 touched vitest files.

Please review the resolution. A maintainer push makes the maintainer the last pusher, so a second approver is needed under the repo's last-push rule. Reply if anything looks wrong.

@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention merge conflict Branch has merge conflicts with its base — author must resolve before merge labels Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5, fork) — 🟡 CONCERNS

Premise-level review of c53d1a25122a54a107873c339261d8a68a654527 via the fork AI-review pipeline — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All hunks check out: no deleted pins, this PR's own items each have a named harm, the core connections module can't do this job (it deliberately never holds a credential), SecretField reuse is real, and the committed screenshots follow the documented temp-screenshots/ convention. One premise risk remains: the diff carries the stacked #2190 handshake whose live-tenant behavior was never exercised, by the author's own admission.

First-Principles-Verdict: CONCERNS

The OAuth handshake ships never run against a real tenant; "Google and Microsoft accept a loopback redirect on any port" is an untested provider claim.

Not justified as shipped

  1. The feat(meetings): read the user's calendar over CalDAV, Google and Microsoft 365 #2190 stacked base — rides along: declared ("Stacked on feat(meetings): read the user's calendar over CalDAV, Google and Microsoft 365 #2190… Rebase onto main once feat(meetings): read the user's calendar over CalDAV, Google and Microsoft 365 #2190 lands"), but it is in this diff until then, and it carries the unverified provider claim below.

What this change ships

Intent: let a user connect CalDAV / Google / Microsoft 365 calendars from Settings instead of hand-crafting curl requests — an ADDITION.

  1. feat(meetings): read the user's calendar over CalDAV, Google and Microsoft 365 #2190's providers, credential store, OAuth routes, sandbox/security leaves — rides along (declared stacked base, under its own review)
  2. GET /calendar/credentials also answers each provider's form shape — justified
  3. Settings → Calendar shows write-only credential fields for the active provider — justified
  4. Connected / Credentials saved / Not connected badge — justified
  5. "Sign in with {provider}", disabled until a client id is stored — justified
  6. Popup-blocked fallback link to the consent URL — justified
  7. Disconnect button once anything is stored — justified
  8. Credential status refetches on window focus — justified
  9. 21 strings in all 12 locales with translator context — justified
  10. Spec section, route table, and five convention-conformant screenshots — justified

Watch

Subtractions

  • Drop the export on CREDENTIALS_QUERY_KEY in CalendarCredentials.tsx — grep CREDENTIALS_QUERY_KEY: 2 uses, both inside the same file; 0 external consumers.

[FIRST-PRINCIPLES-REVIEWED] c53d1a2

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5, fork) — 🟡 CONCERNS

UX-level review of c53d1a25122a54a107873c339261d8a68a654527 via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

I have everything I need: the full frontend diff, the English strings, the callback HTML, and the sibling Connections surface for comparison. The fork's screenshots are not materialized in this checkout, so no first-time reader has seen any of the new controls.

UX-Verdict: CONCERNS

Solid write-only credential flow, but "Connected" is claimed unverified, the popup-blocked toast describes a tab that never opened, and no cold reader has seen any of it.

Watch

  • CalDAV shows the statusConnected badge ("Connected") the moment username+password are stored (isConnected = schema.fields.every(...)), with no verification and no test affordance — a typo'd password reads Connected and fails only at the next sync, on a different surface. The sibling ConnectionsPage deliberately splits connected from not-verified and ships "Test connection". Frequent (every CalDAV setup) × misleading state × persists until sync. Smallest fix: a "Credentials saved" badge for password providers too, or a test call behind Save.
  • On sign-in, notify(connectStarted) ("Finish signing in in the tab that opened, then come back here.") fires even when window.open returned null — a popup-blocked user is told to look for a tab that doesn't exist while the real next step is the small link below. Fix: branch the toast on opened.
  • A first-load failure of the credentials query renders the whole component null (if (!schema) return null), so Google/CalDAV show no form, no error — indistinguishable from "this provider needs nothing". The credentialsUnavailable line is reachable only after a cached success. Rare × silent task failure. Fix: render the error line (via ErrorNotice) when query.isError even without a schema.
  • "Client ID" / "Client secret" arrive with zero pointer to where they come from (a Google Cloud / Azure app registration); SecretField has a setupLink "where do I get this" affordance this PR leaves unused. First-run Google/M365 users are blocked on knowledge the page never offers.

Evidence gaps

  • Fork lane: the five committed screenshots are binary markers only and no blind read ran — every new control (three status badges, the credential SecretFields, "Save credentials", "Sign in with {provider}" incl. its disabled hint, "Disconnect", the toasts) is unseen by a first-time reader. Push the branch to this repository to run the blind read.
  • The popup-blocked fallback link ("Open the sign-in page") and the three OAuth callback pages ("Calendar connected" / "Connection cancelled" / "Could not finish connecting") appear in no screenshot, committed or described.

Suggestions

  • _callback_page "Connection cancelled": f"The calendar provider reported: {denial}" renders raw OAuth codes (access_denied) — map the common ones to prose ("You declined access…") and keep the code as a fallback.
  • Wire setupLink on the client_id/client_secret fields to each provider's app-registration page.

[UX-REVIEWED] c53d1a2

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5, fork) — 🔴 BLOCK (blocking)

Design-level review of c53d1a25122a54a107873c339261d8a68a654527 via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: BLOCK

The OAuth callback sits behind token auth with no bypass, so the consent leg the PR never tested cannot complete from the Electron shell.

Blockers

OAuth sign-in cannot complete on the desktop shell (and this is exactly the leg manual verification skipped)
The component relies on "the Electron shell forwards that to the OS browser" (window-lifecycle.js:828 sends cross-origin window.open to shell.openExternal), so the provider redirects the OS browser — which holds no dashboard cookie — to GET /api/apps/meetings/calendar/oauth/callback. That route is registered behind the gateway's token middleware, which denies token-less requests even on loopback (dashboard/token_auth.py:2611, _deny "Token required") and has no bypass matching this path; the handler's own state check never runs. Result: a desktop user consents at Google and lands on a 403 JSON page, tokens are never stored, with no in-product remedy. The plain-browser case survives only because the SameSite=Lax cookie rides the top-level redirect — and the PR concedes "The OAuth consent itself was not completed against a live Google or Microsoft tenant", i.e. the one branch CI and the screenshots cannot exercise is the broken one.
Fix: exempt the exact callback path (GET-only) from token auth and let the handler's constant-time state check be its admission — the standard RFC 8252 loopback arrangement — then complete one live consent from the Electron shell.
Clears when: a live consent started from the Electron shell (or any browser holding no dashboard session) reaches the "Calendar connected" page, or the callback route gains a state-gated auth exemption with a test pinning it.

Watch

Five binary screenshots are committed under temp-screenshots/ and hot-linked from the description; merging puts them in main's history permanently, where the directory name itself says they don't belong.
Clears when: the PNGs are dropped from the branch and the images are attached to the PR description instead.

Suggestions

  • The description's claim that the schema "keeps an out-of-repo provider able to describe its own form" isn't delivered: _CREDENTIAL_FIELDS/_OAUTH_CLIENTS are closed dicts in the routes module, unreachable from register_calendar_provider. Declare credential fields on the provider registration row and derive both the allowlist and the GET schema from it.

[DESIGN-REVIEWED] c53d1a2

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review (fork) — 🔴 changes requested (blocking)

Reviewed c53d1a25122a54a107873c339261d8a68a654527 via the fork AI-review pipeline; updated in place on each push.

Blocks: two blocking: true frontend-rule violations in the new CalendarCredentials.tsx — an error surface that bypasses ErrorNotice, and a three-button row.

[BLOCK-MERGE] c53d1a2

BLOCKING — website/src/apps/meetings/components/CalendarCredentials.tsx:153
{query.isError && (<p className="text-[12px] text-danger">{i18nT('apps.meetings.settings.credentialsUnavailable')}</p>)}
GET /calendar/credentials fails → query.isError true → a load failure renders in a hand-written text-danger <p>, discarding the ErrorNotice structured context and the askAgent hand-off — a violation of blocking: true errors-use-error-notice (file-patterns src/**/*.tsx), whose enumerated shapes include "an error string dropped into a <p>".
Fix: <ErrorNotice message={i18nT('apps.meetings.settings.credentialsUnavailable')} variant="inline" askAgent /> — a load failure has nothing to lose, so askAgent on.

BLOCKING — website/src/apps/meetings/components/CalendarCredentials.tsx:171
<div className="flex items-center gap-2 flex-wrap"> — Save (175), Sign in (183, gated on schema.oauth), Disconnect (197, gated on stored.length > 0)
An OAuth provider (google/microsoft) with a stored client_id makes schema.oauth and stored.length > 0 both true — the primary "saved client id, now sign in" state — so three peer <Btn> mount as siblings in one flex group, violating blocking: true max-two-buttons-per-row; flex-wrap is explicitly not the fix.
Fix: move Disconnect into an overflow DropdownMenu (per CronRowActions.tsx), keeping ≤2 inline.

FINDING — website/src/apps/meetings/components/CalendarCredentials.tsx:120 — window.open(response.authorize_url, '_blank', 'noopener,noreferrer') returns null per spec whenever noopener is set, so opened is always null and setAuthorizeUrl(opened ? null : response.authorize_url) always sets the URL → the "open sign-in page" fallback link renders on every sign-in, even when the popup opened, and a real popup-block can never be distinguished → Fix: detect blocking without the return value (open without noopener, null-check, then set opener = null), or always render the link.

[OPUS-REVIEWED] c53d1a2

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review (fork) — 🔴 changes requested (blocking)

Reviewed c53d1a25122a54a107873c339261d8a68a654527 via the fork AI-review pipeline; updated in place on each push.

2 of 4 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands.

BLOCKING -- website/src/apps/meetings/components/CalendarCredentials.tsx:92 -- Credential failures lack a persistent ErrorNotice
onError: ... => notify(...) / if (!schema) return null
Rejected request -> toast-only or blank component -> actionable failure state disappears
Anchor: errors-use-error-notice
Fix: Render query and mutation failures with ErrorNotice, documenting the no-hand-off decision for drafts.

BLOCKING -- website/src/apps/meetings/components/CalendarCredentials.tsx:171 -- OAuth credentials render three sibling buttons
<Btn>Save</Btn> ... <Btn>Sign in</Btn> ... <Btn>Disconnect</Btn>
Stored OAuth client ID -> three actions share one group -> action-row cap is exceeded
Anchor: max-two-buttons-per-row
Fix: Move Disconnect into a separate region or overflow menu.

BLOCKING -- src/kiro_crew/apps/builtins/meetings/backend/credentials.py:284 -- Disconnect can be undone by an in-flight token refresh (origin: validation)
with _STORE_LOCK:
Expired token + concurrent sync and Disconnect -> refresh reads old credentials before deletion and writes tokens afterward -> the disconnected provider is recreated
Anchor: residual/crash-data-loss-corruption
Fix: Serialize disconnect against the complete refresh operation or reject writes predating a disconnect generation.

BLOCKING -- src/kiro_crew/apps/builtins/meetings/backend/providers/calendar.py:1071 -- Cross-origin redirects resend OAuth secrets (origin: validation)
verb, target.url, headers=sent or None, data=body, allow_redirects=False
Token endpoint returns a cross-origin 307/308 -> redirect loop reuses the encoded body -> authorization code, verifier, refresh token, or client secret reaches the redirect target
Anchor: residual/security
Fix: Refuse cross-origin redirects whenever a request body is present.

FINDING -- src/kiro_crew/apps/builtins/meetings/backend/credentials.py:183 -- "file is rewritten wholesale on the next write" contradicts writes refusing an unreadable store -> Fix: state that manual repair or replacement is required.

FINDING -- src/kiro_crew/apps/builtins/meetings/backend/providers/calendar.py:10 -- "Three implementations ship" contradicts the five registered implementations -> Fix: update the count and include Google and Microsoft.

[BLOCK-MERGE] c53d1a2
[GPT-REVIEWED] c53d1a2

Adjudication (Opus 4.8) — is blocking on each finding proportionate?

I've verified all four findings against the code. Both adjudicable findings anchor to AUTOSDE rules carrying blocking: true; the fenced findings I assessed for the FLAG/UPHOLD-FENCED evidence bar.

F1errors-use-error-notice (website/AUTOSDE.yaml:527, blocking: true). The query-error branch renders a hand-written <p className="text-[12px] text-danger"> (patch line 5029-5033), which the rule bans outright as its own blocking trigger. Rule flag is authoritative.

F2max-two-buttons-per-row (website/AUTOSDE.yaml:231, blocking: true). The single flex row (CalendarCredentials.tsx:171) holds Save + Sign in + Disconnect; for an OAuth provider with a stored client_id all three render as siblings. Rule flag is authoritative.

F3 — Real read-before-delete / write-after race. access_token reads credentials and posts to the network with no lock (oauth.py patch 757–772), then write_for merges under _STORE_LOCK (credentials.py:284/patch 465); clear_for deletes under the same lock (patch 490). A disconnect landing inside the refresh's network window resurrects the provider with fresh token fields. The _STORE_LOCK only serializes writes — it does not close the read-outside-lock gap the comment claims it does. Recovery: none. Conditions are an ordinary background-sync/disconnect interleaving, not extreme — no FLAG argument available.

F4 — Body (carrying code, verifier, refresh_token, client_secret) is resent on every hop including cross-origin 307/308 (calendar.py:1071/patch 1127, data=body; secrets placed in body at oauth.py patch 802–815). But token_url is the hardcoded constant GOOGLE_TOKEN_URL/MICROSOFT_TOKEN_URL (patch 140/172), used directly by the factories (patch 1617/1810) with no config seam; TLS is default-verified (no ssl= override, docstring patch 1084) and DNS is pinned per hop (patch 1120). Producing a cross-origin redirect from that endpoint requires compromising Google/Microsoft's own TLS-verified token host — under which hop-0 already receives the secrets, so the redirect adds no marginal exposure. Event fetches carry their bearer in auth_headers, dropped cross-origin (patch 1591–1593). This meets the FLAG bar: the triggering input cannot be produced by anyone the system does not already fully trust.

[ADJUDICATION] c53d1a2 total=2 uphold=2 downgrade=0
UPHOLD F1 website/src/apps/meetings/components/CalendarCredentials.tsx:92 reason=autosde-blocking-rule
UPHOLD F2 website/src/apps/meetings/components/CalendarCredentials.tsx:171 reason=autosde-blocking-rule
[GPT-ADJUDICATED] c53d1a2
[ADJUDICATION-FENCED] c53d1a2 fenced=2 flagged=1
UPHOLD-FENCED F3 src/kiro_crew/apps/builtins/meetings/backend/credentials.py:284 -- An ordinary background-refresh/disconnect interleaving resurrects the disconnected provider because the refresh reads credentials and does its network round-trip outside the lock; no rarity or recovery argument holds.
FLAG F4 src/kiro_crew/apps/builtins/meetings/backend/providers/calendar.py:1071 -- token_url is a hardcoded TLS-verified Google/Microsoft constant with no config seam, so a cross-origin body-resending redirect requires compromising that provider, under which the secrets already leak on the first request — no marginal exposure the trusted endpoint doesn't already hold.
[GPT-ADJUDICATED-FENCED] c53d1a2

🏷️ Fenced finding(s) machine-flagged as likely edge case

The security fence keeps these findings blocking regardless of adjudication; the only clearance path is a human override recorded by a repository writer, who must independently verify a rationale before recording it — it is machine-authored, and a wrong override on a security-class finding ships exactly the class the fence exists to stop. (This lane's comment deliberately carries no override command.)

  • F4 src/kiro_crew/apps/builtins/meetings/backend/providers/calendar.py:1071 — token_url is a hardcoded TLS-verified Google/Microsoft constant with no config seam, so a cross-origin body-resending redirect requires compromising that provider, under which the secrets already leak on the first request — no marginal exposure the trusted endpoint doesn't already hold.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fork Pull request from a fork (external contributor) needs-author-decision PR blocked on author input readiness: action required A blocking check or review needs attention

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants