From 672738aeafda12b5db33ab7e6a3bf3b272833a35 Mon Sep 17 00:00:00 2001 From: Darko Mesaros <5794513+darko-mesaros@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:51:41 +0000 Subject: [PATCH] fix(usage): read the social-login token key in the free credit API path The kiro_usage_api module's _SQLITE_TOKEN_KEYS tuple only contained the OIDC and legacy CodeWhisperer keys. Users signed in via GitHub social login store their bearer token under kirocli:social:token, which was never searched -- leaving the free GetUsageLimits path non-functional for this login class. Since #2039 made the text scrape opt-in (default off), these users see no credit pill at all. Add kirocli:social:token to the key list. The token blob has the same {access_token, expires_at} shape the existing parser expects; no format change is needed. Verified end-to-end on a real social-login host. Closes #2291 --- .../modules/learn-cron-dashboard.md | 2 +- .../dashboard/handlers/kiro_usage_api.py | 2 +- test/test_kiro_usage_api.py | 42 +++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/system-specs/modules/learn-cron-dashboard.md b/docs/system-specs/modules/learn-cron-dashboard.md index 9657c8c5cd4..6a65a0c9725 100644 --- a/docs/system-specs/modules/learn-cron-dashboard.md +++ b/docs/system-specs/modules/learn-cron-dashboard.md @@ -630,7 +630,7 @@ Bounds (`validation.py`, single source of truth) still gate the tool's arguments **Agents**: GET `/api/agents` (KiroCrew agent roster ordered **most-used-first** — reorders config agents + discovered project agents by `ConversationLog.agent_usage()` (turn count, then recency), falling back gracefully to config-insertion order on any failure so the dropdown never breaks or drops agents), GET `/api/agents/installed` (list all kiro-cli agents from `~/.kiro/agents/`, with `package` field extracted from filename), GET/DELETE `/api/agents/detail/{name}` (full agent config JSON; DELETE removes the config file, protected for kirocrew/kirocrew-lite) **Capability Integration** (edition-supplied operations-based `CapabilityManager` seam — the edition owns its CLI grammar, output parsing, and error translation; on a vanilla OSS install `CapabilityManager.available()` is `False` so every endpoint returns HTTP 503 `"capability manager not available"`): GET `/api/capability/mcp` (list installed MCP servers), POST `/api/capability/mcp/install` (install an MCP server, pushes `refresh("agents")`), POST `/api/capability/mcp/uninstall` (pushes `refresh("agents")`), GET `/api/capability/mcp/registry` (browse available MCP servers — the manager returns already-parsed entries, which the core passes through as `{"servers": [...]}`), GET `/api/capability/skills` (list installed skill packages), POST `/api/capability/skills/install` (install by `package` only — **no `version_set`**; the manager owns version/source resolution; regenerates agent config, pushes `refresh("agents")`; the manager returns human-friendly errors), POST `/api/capability/skills/uninstall` (pushes `refresh("agents")`), GET `/api/capability/agents` (list installed agent packages), POST `/api/capability/agents/install` + POST `/api/capability/agents/uninstall` (by `package` only, same no-`version_set` rule as skills; both rebuild the agent config off the event loop, clear the `list_agents()` cache — it keys on a `(count, newest-mtime-ns)` signature a same-tick mutation would not bump — and push `refresh("agents")`), GET `/api/capability/plugins` (installed client-plugin packages **plus** `out_of_sync`, the drift set of packages installed as agents but missing their plugin counterpart; both reads are `asyncio.gather`ed so the endpoint stays inside ONE `CAPABILITY_READ_TIMEOUT` — it is polled), POST `/api/capability/plugins/sync` (reconcile that drift). Package names are allowlisted (`_is_valid_capability_package`: length cap, `..` rejection, charset) before crossing into the edition manager, manager messages are `_redact_external`-scrubbed + length-bounded on BOTH success and failure paths, and each mutation emits an explicit SEL line naming the package (the audit middleware logs only the request path). The routes were renamed from the former `/api/aim/*` to neutral `/api/capability/*` vocab so no Amazon-internal name fossilizes in the fork's public API. **Removed cross-provider bridge** (fully **deleted**): the kiro→removed-provider mirror (`/api/cc/mirror/preview` + `/api/cc/mirror/run`, `mirror_kiro_to_cc`, the `ProviderPanel.tsx` migrate-from-kiro UI, the `kirocrew mirror kiro-to-cc` CLI) was removed when KiroCrew collapsed to a single KiroACP / `kiro-cli` backend. The later cross-provider AIM plugin bridge (`GET /api/cc/aim/missing`, `POST /api/cc/aim/sync`, plus `install_cc_plugin`, `installed_kiro_packages_missing_from_cc`, and the `_VALID_PACKAGE_RE`/`~/.aim/cc-plugins` machinery) was **also removed** with the pure-AIM code — there are no `/api/cc/aim/*` routes. Provider-specific MCP config is now handled uniformly through the `extra_mcp_scopes()` seam (see `platform-context.md`): the core manages the Kiro global only, and a companion re-adds its provider scope for both apply/uninstall AND discovery. On `/api/mcp/apply` the omitted-field default differs by scope family — the core `kiroGlobal` key is `omit → delete` (the bundled SPA always sends it) while every seam `f"{id}Global"` key is `omit → preserve` (defaults to current on-disk presence), so an OSS apply that omits a companion scope never deletes that provider's server; see the `extra_mcp_scopes()` contract note in `platform-context.md`. -**Sessions**: GET `/api/sessions` (paginated list), GET `/api/sessions/{key}` (detail), DELETE `/api/sessions/{key}` (permanent delete), GET `/api/sessions/context` (context usage), GET `/api/sessions/usage` (kiro credit usage, cached 10 min; the background refresh tries the real CodeWhisperer RTS `GetUsageLimits` API first — the true used/limit/overage — and falls back to scraping `kiro-cli chat --no-interactive --agent kirocrew-lite /usage` stdout when the API path is unavailable), POST `/api/sessions/summarize` (one-line LLM summaries for a list of session keys — bounded to 8, generated on an ephemeral background session with the cheap Haiku model, best-effort; backs the `list_sessions` MCP tool's opt-in `summarize=true`), GET `/api/sessions/memory` (per-session and per-task memory footprint; returns `{sessions, tasks, totals, history, unattributed}` — each session row carries: `key`, `title`, `slot_key`, `untitled`, `agent`, `channel` (the grouping dimension, resolved by `telemetry_channel_of(key)` from `kiro_crew.messaging.link` — bounded cardinality, same taxonomy as telemetry metrics), `pid`, `owns_runtime`, `rss_mb`, `procs`, `mcp`, `cpu_cores`, `prompts`, `uptime_s`, `credits` (cumulative kiro credits consumed over the spend window — `SPEND_WINDOW_DAYS` in `dashboard/handlers/usage.py`, which the Telemetry spend tab's `cost_breakdown` shares so the two surfaces cannot report different totals for one session; `null` when no measured turn exists in the window, which is semantically distinct from zero; the join is by session key, and because a slot bound to a channel or cron conversation runs its turns under `linked_session_key` while its usage rows are still filed under the dashboard `slot.key`, the lookup falls back to `DashboardState.spend_slot_by_session()` — without that reverse index those sessions report `null` despite having spent), `turns` (count of completed turns in the same window — `null` same semantics as `credits`); `totals` carries `rss_mb`, `host_mb`, `host_pct`, `runtimes`, `rss_is_upper_bound`; `history` is a ring of `{t, mb}` snapshots; `tasks` is the subagent task list from `SubagentManager.task_memory_rows()`; `unattributed` is `{procs, rss_mb, oldest_uptime_s}` summarizing agent runtimes alive on the machine that this gateway does not own — `null` (not a zero record) on platforms where processes cannot be enumerated, so the UI can distinguish "none found" from "cannot look") +**Sessions**: GET `/api/sessions` (paginated list), GET `/api/sessions/{key}` (detail), DELETE `/api/sessions/{key}` (permanent delete), GET `/api/sessions/context` (context usage), GET `/api/sessions/usage` (kiro credit usage, cached 10 min; the background refresh tries the real CodeWhisperer RTS `GetUsageLimits` API first — the true used/limit/overage — reading bearer tokens from `kiro-cli`'s own SQLite auth store under keys `kirocli:odic:token` (OIDC/Builder ID), `codewhisperer:odic:token` (legacy), `kirocli:social:token` (GitHub/Google social login), and `kirocli:external-idp:token` (Identity Center/org SSO), then falls back to scraping `kiro-cli chat --no-interactive --agent kirocrew-lite /usage` stdout when the API path is unavailable and `dashboard.usage_text_scrape_enabled` is true), POST `/api/sessions/summarize` (one-line LLM summaries for a list of session keys — bounded to 8, generated on an ephemeral background session with the cheap Haiku model, best-effort; backs the `list_sessions` MCP tool's opt-in `summarize=true`), GET `/api/sessions/memory` (per-session and per-task memory footprint; returns `{sessions, tasks, totals, history, unattributed}` — each session row carries: `key`, `title`, `slot_key`, `untitled`, `agent`, `channel` (the grouping dimension, resolved by `telemetry_channel_of(key)` from `kiro_crew.messaging.link` — bounded cardinality, same taxonomy as telemetry metrics), `pid`, `owns_runtime`, `rss_mb`, `procs`, `mcp`, `cpu_cores`, `prompts`, `uptime_s`, `credits` (cumulative kiro credits consumed over the spend window — `SPEND_WINDOW_DAYS` in `dashboard/handlers/usage.py`, which the Telemetry spend tab's `cost_breakdown` shares so the two surfaces cannot report different totals for one session; `null` when no measured turn exists in the window, which is semantically distinct from zero; the join is by session key, and because a slot bound to a channel or cron conversation runs its turns under `linked_session_key` while its usage rows are still filed under the dashboard `slot.key`, the lookup falls back to `DashboardState.spend_slot_by_session()` — without that reverse index those sessions report `null` despite having spent), `turns` (count of completed turns in the same window — `null` same semantics as `credits`); `totals` carries `rss_mb`, `host_mb`, `host_pct`, `runtimes`, `rss_is_upper_bound`; `history` is a ring of `{t, mb}` snapshots; `tasks` is the subagent task list from `SubagentManager.task_memory_rows()`; `unattributed` is `{procs, rss_mb, oldest_uptime_s}` summarizing agent runtimes alive on the machine that this gateway does not own — `null` (not a zero record) on platforms where processes cannot be enumerated, so the UI can distinguish "none found" from "cannot look") **Logs**: GET `/api/logs` (SSE), GET/POST `/api/logs/level` (runtime log level control) **Task Runner**: GET `/api/taskrunner` (status with runs[], includes `agent`), POST `/api/taskrunner` (start, optional `agent` field), POST `/api/taskrunner/cancel` (per-task or all), DELETE `/api/taskrunner/{task_id}` (remove finished run), POST `/api/taskrunner/refine` (dynamic multi-turn with tool access), GET `/api/taskrunner/refine` (status with `waiting` field), POST `/api/taskrunner/refine/cancel`, POST `/api/taskrunner/refine/answer` (answer clarifying question) **Approvals**: GET `/api/approvals` (pending list), POST `/api/approvals/{id}/approve`, POST `/api/approvals/{id}/reject` diff --git a/src/kiro_crew/dashboard/handlers/kiro_usage_api.py b/src/kiro_crew/dashboard/handlers/kiro_usage_api.py index 61701aab6b1..d8199a344a1 100644 --- a/src/kiro_crew/dashboard/handlers/kiro_usage_api.py +++ b/src/kiro_crew/dashboard/handlers/kiro_usage_api.py @@ -147,7 +147,7 @@ Path.home() / ".local" / "share" / "amazon-q" / "data.sqlite3", Path.home() / "Library" / "Application Support" / "amazon-q" / "data.sqlite3", ) -_SQLITE_TOKEN_KEYS = ("kirocli:odic:token", "codewhisperer:odic:token") +_SQLITE_TOKEN_KEYS = ("kirocli:odic:token", "codewhisperer:odic:token", "kirocli:social:token", "kirocli:external-idp:token") # SEL audit label for the SQLite live-token read. Not an allowlist entry (that # gate is for sensitive-path reads via safe_read_file_internal); this is only diff --git a/test/test_kiro_usage_api.py b/test/test_kiro_usage_api.py index ca89b86ef71..a97a2dd05fd 100644 --- a/test/test_kiro_usage_api.py +++ b/test/test_kiro_usage_api.py @@ -350,6 +350,48 @@ def test_sqlite_opened_but_no_token_still_audits(self, tmp_path): assert api._token_from_sqlite(db, datetime.now(timezone.utc)) is None audit.assert_any_call(api._SQLITE_AUDIT_READ_ID, "no_token") + def test_social_login_token_key_is_recognized(self, tmp_path): + # GitHub social login stores its bearer token under a different key + # (kirocli:social:token) than the OIDC flow (kirocli:odic:token). + # The API module must read it from kiro-cli's own store. + db = tmp_path / "data.sqlite3" + con = sqlite3.connect(str(db)) + con.execute("CREATE TABLE auth_kv (key TEXT PRIMARY KEY, value TEXT)") + future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() + con.execute( + "INSERT INTO auth_kv VALUES (?, ?)", + ("kirocli:social:token", + json.dumps({"access_token": "social-tok", "expires_at": future})), + ) + con.commit() + con.close() + with patch("kiro_crew.hooks.safe_read_file_internal", return_value=None), \ + patch("kiro_crew.hooks.emit_internal_read_audit", return_value=True), \ + patch.object(api, "_CLI_SQLITE_DBS", (db,)), \ + patch.object(api, "_OTHER_SQLITE_DBS", ()): + assert api._load_bearer_token() == "social-tok" + + def test_external_idp_token_key_is_recognized(self, tmp_path): + # Identity Center (org/enterprise SSO) stores its bearer token under + # kirocli:external-idp:token. On Linux the JSON SSO cache is not + # refreshed, so this SQLite key is the only live source. + db = tmp_path / "data.sqlite3" + con = sqlite3.connect(str(db)) + con.execute("CREATE TABLE auth_kv (key TEXT PRIMARY KEY, value TEXT)") + future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() + con.execute( + "INSERT INTO auth_kv VALUES (?, ?)", + ("kirocli:external-idp:token", + json.dumps({"access_token": "idp-tok", "expires_at": future})), + ) + con.commit() + con.close() + with patch("kiro_crew.hooks.safe_read_file_internal", return_value=None), \ + patch("kiro_crew.hooks.emit_internal_read_audit", return_value=True), \ + patch.object(api, "_CLI_SQLITE_DBS", (db,)), \ + patch.object(api, "_OTHER_SQLITE_DBS", ()): + assert api._load_bearer_token() == "idp-tok" + class TestPostSecurityControls: def test_post_sets_headers_method_and_url(self):