From 500aa5d1d6f1f87ec1b14541d90e03437bf8dac3 Mon Sep 17 00:00:00 2001 From: LB7666 Date: Thu, 2 Jul 2026 04:51:44 +0800 Subject: [PATCH] feat(skills): support GitHub Kiro relogin workflows --- ...ithub-account-suspension-appeal-tracker.md | 37 + .../github-account-suspension-appeal/SKILL.md | 100 ++ .../agents/openai.yaml | 4 + .../tests/test_skill_contract.py | 61 ++ skills/kiro-social-google-onboarder/SKILL.md | 81 -- .../agents/openai.yaml | 4 - skills/kiro-social-onboarder/SKILL.md | 163 ++++ .../kiro-social-onboarder/agents/openai.yaml | 4 + .../scripts/drive_kiro_social_github.mjs | 232 +++++ .../scripts/drive_kiro_social_google.mjs | 0 .../scripts/onboard_kiro_social_github.py | 852 ++++++++++++++++++ .../scripts/onboard_kiro_social_google.py | 0 .../tests/test_onboard_kiro_social_github.py | 424 +++++++++ 13 files changed, 1877 insertions(+), 85 deletions(-) create mode 100644 docs/github-account-suspension-appeal-tracker.md create mode 100644 skills/github-account-suspension-appeal/SKILL.md create mode 100644 skills/github-account-suspension-appeal/agents/openai.yaml create mode 100644 skills/github-account-suspension-appeal/tests/test_skill_contract.py delete mode 100644 skills/kiro-social-google-onboarder/SKILL.md delete mode 100644 skills/kiro-social-google-onboarder/agents/openai.yaml create mode 100644 skills/kiro-social-onboarder/SKILL.md create mode 100644 skills/kiro-social-onboarder/agents/openai.yaml create mode 100755 skills/kiro-social-onboarder/scripts/drive_kiro_social_github.mjs rename skills/{kiro-social-google-onboarder => kiro-social-onboarder}/scripts/drive_kiro_social_google.mjs (100%) create mode 100755 skills/kiro-social-onboarder/scripts/onboard_kiro_social_github.py rename skills/{kiro-social-google-onboarder => kiro-social-onboarder}/scripts/onboard_kiro_social_google.py (100%) create mode 100644 skills/kiro-social-onboarder/tests/test_onboard_kiro_social_github.py diff --git a/docs/github-account-suspension-appeal-tracker.md b/docs/github-account-suspension-appeal-tracker.md new file mode 100644 index 00000000..b85e6b09 --- /dev/null +++ b/docs/github-account-suspension-appeal-tracker.md @@ -0,0 +1,37 @@ +# GitHub Account Suspension Appeal Tracker + +Created: 2026-07-02 + +Purpose: track GitHub-backed Kiro accounts that failed relogin because the +GitHub account appeared suspended, disabled, or otherwise abnormal, so appeals +can be filed one by one later. + +## Appeal Queue + +| GitHub username | Observed status | Evidence/source | Appeal status | Notes | +|---|---|---|---|---| +| `gfryuuu` | Suspended/disabled | GitHub relogin attempt reached a suspended-account state; user prepared an appeal for this account | Not submitted in repo | Include username in subject and description. Student/inactivity context: about two months if still accurate. | +| `kdhhrj` | Suspended/disabled | GitHub relogin attempt was blocked by a suspended-account state | Not submitted in repo | Recheck exact GitHub support page text before drafting. | +| `hfdery` | Suspended/disabled | User confirmed this account was also disabled during relogin triage | Not submitted in repo | Recheck exact GitHub support page text before drafting. | +| `ljhyugg` | Suspended/disabled | User confirmed this account was gone/disabled during relogin triage | Not submitted in repo | Recheck exact GitHub support page text before drafting. | +| `hfdryhy` | Suspended/disabled | User confirmed this account was also disabled during relogin triage | Not submitted in repo | Recheck exact GitHub support page text before drafting. | +| `hfdvbgt` | Abnormal during relogin | User reported it was not normal during relogin triage | Not submitted in repo | Verify whether GitHub labels it suspended, disabled, locked, or another restriction before appeal wording. | +| `hfdegh` | Abnormal during relogin | User reported it was not normal during relogin triage | Not submitted in repo | Verify whether GitHub labels it suspended, disabled, locked, or another restriction before appeal wording. | +| `zjhferw` | Abnormal during relogin | User reported it was not normal during relogin triage | Not submitted in repo | Verify whether GitHub labels it suspended, disabled, locked, or another restriction before appeal wording. | + +## Already Recovered + +| GitHub username | Current state | Notes | +|---|---|---| +| `usmore` | Recovered and reimported into Kiro | Do not include in the appeal queue unless it becomes restricted again. Email recorded in llm-access as `unsmore@utexas.edu`. | + +## Appeal Preparation Checklist + +For each queued account: + +1. Reopen the GitHub login/support page and capture the exact restriction text. +2. Draft a fresh appeal with `skills/github-account-suspension-appeal`. +3. Use the exact username in both the subject and the restricted-account field. +4. Mention the student/inactivity context only when it is true for that account. +5. Do not claim hacking, exact dates, or policy compliance history unless there is evidence. +6. Update `Appeal status` after submission, including any GitHub ticket ID if shown. diff --git a/skills/github-account-suspension-appeal/SKILL.md b/skills/github-account-suspension-appeal/SKILL.md new file mode 100644 index 00000000..2e0e73ab --- /dev/null +++ b/skills/github-account-suspension-appeal/SKILL.md @@ -0,0 +1,100 @@ +--- +name: github-account-suspension-appeal +description: Use when a GitHub account is suspended, restricted, limited, disabled, locked, or blocked and the user needs an appeal, support ticket, account limitation explanation, or form-field wording in English. +--- + +# GitHub Account Suspension Appeal + +## Overview + +Prepare concise, truthful English appeals for GitHub account restrictions. The goal is to help Support review the restriction, not to argue, threaten, confess unknown wrongdoing, or reuse a stale template. + +## Intake + +Collect only facts the user can stand behind: + +- restricted account username +- visible error text, such as `Account suspended` or a Terms of Service notice +- rough inactivity period, such as `about two months` +- identity/context, such as being a student with exams, coursework, internship work, travel, or other ordinary reasons for not logging in +- what changed today: tried to log in, resume school/project work, or connect a service +- whether the user is willing to verify account ownership or provide more information + +If a fact is missing, omit it or ask for it. Do not invent repository activity, dates, organizations, school names, locations, or previous compliance history. + +## Form Fields + +For GitHub Support forms, map intent to fields this way: + +| Field | Preferred answer | +|---|---| +| Subject | Include the username and appeal intent, e.g. `Appeal for Suspended GitHub Account: ` | +| Please describe your account limitation issue | Explain the restriction, the inactive period, the student reason, lack of known violation, and willingness to verify | +| Username associated with the restricted account | The exact username only | +| Need help removing domains or content from a repository? | Usually `No` for account suspension appeals | +| Type of Issue | Prefer an account-access or restricted-account option if present; if forced between error and API rate limit, choose the error/bug option, not API rate limit | + +Use the current form labels if they differ. Do not overfit to old GitHub UI wording. + +## Writing Rules + +- Write in calm, respectful English. +- Put the username in both the subject and first paragraph when known. +- Say the account is suspended or restricted based on what the user saw. +- Mention the student context only as a plausible reason for inactivity, not as a demand for special treatment. +- Use `about two months` or another approximate period unless the user provides exact dates. +- Include `willing to verify` ownership or provide additional information. +- Ask GitHub to review and explain the restriction. +- Do not admit wrongdoing unless the user explicitly confirms it. +- Do not claim the account was hacked unless the user has evidence. +- Do not claim exact dates unless provided. +- Do not invent evidence, ticket numbers, school names, repositories, or support history. + +## Variation Rules + +Always generate a fresh draft each time. + +- Do not reuse the same opening. Rotate between openings like `I am writing to appeal...`, `I would like to request a review...`, `I recently discovered...`, or `Could you please review...`. +- Rotate sentence order: sometimes lead with the restriction, sometimes with the username, sometimes with the student/inactivity context. +- Vary the subject line while preserving meaning: + - `Appeal for Suspended GitHub Account: ` + - `Request to Review Account Restriction for ` + - `Suspended Account Appeal - ` + - `Account Access Review Request: ` +- Vary phrasing for uncertainty: + - `I am not aware of any activity that would violate GitHub's policies.` + - `I do not know what triggered the restriction.` + - `If there was a security or policy concern, I would appreciate guidance.` +- Vary the close: + - `I am happy to provide any verification needed.` + - `Please let me know if you need additional information from me.` + - `I would appreciate a review of the restriction and any next steps.` + +Keep the facts stable while changing phrasing. Randomness means varied wording and structure, not changed claims. + +## Output Contract + +Return exactly the fields the user needs, unless they ask for a full ticket: + +```text +Subject: +... + +Please describe your account limitation issue: +... + +What is the username associated with the restricted account? +... +``` + +When the user asks about form choices, answer the choice directly first, then give one short reason. + +## Common Mistakes + +- Treating a suspension appeal as a bug report without asking for review. +- Choosing API rate limit for an account restriction. +- Saying `Yes` to domain/content removal when the user only wants account access restored. +- Writing a generic template that omits the username. +- Repeating the same appeal text for multiple accounts. +- Overexplaining student status instead of keeping it as context. +- Promising future behavior or admitting a violation the user did not confirm. diff --git a/skills/github-account-suspension-appeal/agents/openai.yaml b/skills/github-account-suspension-appeal/agents/openai.yaml new file mode 100644 index 00000000..12f08406 --- /dev/null +++ b/skills/github-account-suspension-appeal/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "GitHub Account Appeal" + short_description: "Draft varied GitHub suspension appeals" + default_prompt: "Use $github-account-suspension-appeal to draft a concise GitHub account suspension appeal." diff --git a/skills/github-account-suspension-appeal/tests/test_skill_contract.py b/skills/github-account-suspension-appeal/tests/test_skill_contract.py new file mode 100644 index 00000000..702d3849 --- /dev/null +++ b/skills/github-account-suspension-appeal/tests/test_skill_contract.py @@ -0,0 +1,61 @@ +import re +import unittest +from pathlib import Path + + +SKILL = Path(__file__).resolve().parents[1] / "SKILL.md" + + +class GithubAccountSuspensionAppealSkillTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.text = SKILL.read_text(encoding="utf-8") + + def test_frontmatter_is_discoverable(self): + self.assertIn("name: github-account-suspension-appeal", self.text) + description = re.search(r"^description: (.+)$", self.text, re.MULTILINE) + self.assertIsNotNone(description) + self.assertTrue(description.group(1).startswith("Use when ")) + for keyword in ("GitHub", "suspended", "restricted", "appeal"): + self.assertIn(keyword, description.group(1)) + + def test_core_workflow_sections_exist(self): + for heading in ( + "## Intake", + "## Form Fields", + "## Writing Rules", + "## Variation Rules", + "## Output Contract", + "## Common Mistakes", + ): + self.assertIn(heading, self.text) + + def test_required_appeal_facts_are_covered(self): + for phrase in ( + "username", + "student", + "about two months", + "Account suspended", + "Terms of Service", + "willing to verify", + ): + self.assertIn(phrase, self.text) + + def test_randomization_is_required_without_fabrication(self): + for phrase in ( + "Do not reuse the same opening", + "Rotate sentence order", + "Do not invent", + "Do not admit wrongdoing", + "Do not claim exact dates unless provided", + ): + self.assertIn(phrase, self.text) + + def test_template_repetition_is_not_allowed(self): + self.assertNotIn("[TODO", self.text) + self.assertNotIn("copy this exact template", self.text.lower()) + self.assertIn("generate a fresh draft", self.text) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/kiro-social-google-onboarder/SKILL.md b/skills/kiro-social-google-onboarder/SKILL.md deleted file mode 100644 index b11eafb7..00000000 --- a/skills/kiro-social-google-onboarder/SKILL.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -name: kiro-social-google-onboarder -description: Automate Kiro CLI account onboarding with social Google login through the required HTTP proxy, local Kiro SQLite auth cleanup without logout, llm-access Kiro account import, proxy assignment, balance refresh, and KIRO STUDENT/1000-credit verification. Use when adding a new Google-backed Kiro account, replacing a mistakenly imported AWS/IDC Kiro login, or verifying that a local Kiro social account is student-tier before exposing it through StaticFlow/llm-access. ---- - -# Kiro Social Google Onboarder - -## Boundaries - -- Never run `kiro-cli logout`; remove local metadata keys instead. -- Never store Google passwords, access tokens, refresh tokens, or raw token JSON in the skill or handoff. -- Use the HTTP proxy for the whole login flow. Default proxy: `http://127.0.0.1:11111`. -- Delete llm-access accounts only by exact explicit name, and only when the user asked to replace or clean that account. -- Keep the final verification centered on `kiro-cli whoami`, `auth_method=social`, `provider=google`, and refreshed Kiro balance. - -## One-Command Flow - -Use the bundled script for the standard path: - -```bash -read -rsp 'Google password: ' KIRO_GOOGLE_PASSWORD; echo -export KIRO_GOOGLE_PASSWORD -python3 skills/kiro-social-google-onboarder/scripts/onboard_kiro_social_google.py \ - --email user@example.com \ - --account-name kiro-user-google-social \ - --replace-account -unset KIRO_GOOGLE_PASSWORD -``` - -The script: - -1. Backs up `~/.local/share/kiro-cli/data.sqlite3`. -2. Deletes only Kiro auth metadata keys from local SQLite. -3. Starts Kiro social Google device authorization through the proxy. -4. Opens an isolated Chrome profile through the proxy and drives the visible Google/Kiro pages through DevTools. - It must inspect the DOM and click real `button`/`a`/`role=button` controls such as `Next`, `Approve`, `Continue`, and `Restart`. -5. Approves the Kiro device code and polls the social token endpoint. -6. Writes `kirocli:social:token` and `api.codewhisperer.profile` locally. -7. Verifies `kiro-cli whoami` through the proxy. -8. Runs `kiro-local-account-importer` dry-run and apply against `http://127.0.0.1:19182`. - Proxy assignment is delegated to the importer, which chooses the least-used - active United States proxy first and uses latency only as a tie-breaker. -9. Refreshes balance and fails unless the account is `KIRO STUDENT` with at least `1000` usage limit by default. -10. Removes temporary browser profiles and token response files. - -## Required Options - -- `--email`: Google account email. -- `--account-name`: llm-access Kiro account name. Prefer `kiro--google-social`. -- Password source: `KIRO_GOOGLE_PASSWORD` by default, or interactive hidden prompt. - -## Useful Options - -- `--proxy http://127.0.0.1:11111`: override the login proxy. -- `--admin-base-url http://127.0.0.1:19182`: override local mapped llm-access admin API. -- `--replace-account`: delete the exact target account before importing. -- `--delete-account-name NAME`: delete an exact mistakenly imported account before the flow. -- `--manual-timeout-seconds 300`: time allowed for manual CAPTCHA/MFA completion if Google requires it. -- `--expect-usage-limit 1000`: required usage limit for verification. -- `--no-expect-student`: allow non-student accounts, but still print the balance. - -## Failure Handling - -- If Google shows CAPTCHA or MFA, complete it in the launched isolated browser; the script keeps polling until the manual timeout. -- If Google shows `Something went wrong` with `Restart`, click `Restart` and continue the same OAuth flow. -- If the import step fails after account creation, query the exact account name before retrying. Do not create a second account name unless the user requests it. -- If balance is not `KIRO STUDENT` or usage limit is below `1000`, report the account as not acceptable and do not hide the failure. -- If a wrong AWS/IDC account was imported, delete that exact account via llm-access admin API, then rerun the social flow. Do not call logout. - -## Verification Commands - -```bash -HTTP_PROXY=http://127.0.0.1:11111 \ -HTTPS_PROXY=http://127.0.0.1:11111 \ -ALL_PROXY=http://127.0.0.1:11111 \ -kiro-cli whoami - -curl -fsS -X POST \ - http://127.0.0.1:19182/admin/kiro-gateway/accounts//balance \ - | jq '{subscription_title, usage_limit, remaining, current_usage, user_id}' -``` diff --git a/skills/kiro-social-google-onboarder/agents/openai.yaml b/skills/kiro-social-google-onboarder/agents/openai.yaml deleted file mode 100644 index 2c2f2476..00000000 --- a/skills/kiro-social-google-onboarder/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Kiro Social Google Onboarder" - short_description: "Automate Kiro social Google login and llm-access import." - default_prompt: "Use Kiro social Google onboarding to log in a Google account through the required proxy, import it into llm-access, and verify student credits." diff --git a/skills/kiro-social-onboarder/SKILL.md b/skills/kiro-social-onboarder/SKILL.md new file mode 100644 index 00000000..6d0d3567 --- /dev/null +++ b/skills/kiro-social-onboarder/SKILL.md @@ -0,0 +1,163 @@ +--- +name: kiro-social-onboarder +description: Automate Kiro CLI account onboarding with social Google or GitHub login through the required HTTP proxy, local Kiro SQLite auth cleanup without logout, email-aware llm-access Kiro account import, proxy assignment, balance refresh, and KIRO STUDENT/1000-credit verification. Use when adding or refreshing a social Kiro account, replacing a mistakenly imported AWS/IDC Kiro login, or verifying that a local Kiro social account is student-tier before exposing it through StaticFlow/llm-access. +--- + +# Kiro Social Onboarder + +## Boundaries + +- Never run `kiro-cli logout`; remove local metadata keys instead. +- Never store provider passwords, 2FA codes, access tokens, refresh tokens, or raw token JSON in the skill or handoff. +- Use the HTTP proxy for the whole login flow. Default proxy: `http://127.0.0.1:11111`. +- Delete llm-access accounts only by exact explicit name, and only when the user asked to replace or clean that account. +- Keep the final verification centered on `kiro-cli whoami`, `auth_method=social`, the expected social provider, and refreshed Kiro balance. + +## One-Command Flow + +Use the bundled script for the standard path: + +```bash +read -rsp 'Google password: ' KIRO_GOOGLE_PASSWORD; echo +export KIRO_GOOGLE_PASSWORD +python3 skills/kiro-social-onboarder/scripts/onboard_kiro_social_google.py \ + --email user@example.com \ + --account-name kiro-user-google-social \ + --replace-account +unset KIRO_GOOGLE_PASSWORD +``` + +For refreshing an existing GitHub-backed account that has fallen into +`auth_401`, use the GitHub-specific script without `--account-name`. It probes +the refreshed credentials with a temporary account, matches the upstream +`user_id` to an existing llm-access Kiro account, then writes the new token +pair back to that existing account: + +```bash +python3 skills/kiro-social-onboarder/scripts/onboard_kiro_social_github.py \ + --manual-timeout-seconds 600 +``` + +For credential-prefilled GitHub login, put the password in an environment +variable rather than a shell argument. The helper fills the GitHub username and +password, then waits for manual 2FA or unusual verification: + +```bash +read -rsp 'GitHub password: ' KIRO_GITHUB_PASSWORD; echo +export KIRO_GITHUB_PASSWORD +python3 skills/kiro-social-onboarder/scripts/onboard_kiro_social_github.py \ + --github-login gfryuuu \ + --manual-timeout-seconds 600 +unset KIRO_GITHUB_PASSWORD +``` + +2FA codes are never passed to the script; complete 2FA and unusual verification +in the launched browser when prompted. + +For importing a new GitHub-backed account under an explicit name, pass +`--account-name`: + +```bash +python3 skills/kiro-social-onboarder/scripts/onboard_kiro_social_github.py \ + --account-name kiro-user-github-social +``` + +The script: + +1. Backs up `~/.local/share/kiro-cli/data.sqlite3`. +2. Deletes only Kiro auth metadata keys from local SQLite. +3. Starts Kiro social device authorization through the proxy. +4. Opens an isolated Chrome profile through the proxy and drives the visible provider/Kiro pages through DevTools. + It must inspect the DOM and click real `button`/`a`/`role=button` controls such as `Next`, `Approve`, `Continue`, and `Restart`. +5. Approves the Kiro device code and polls the social token endpoint. +6. Writes `kirocli:social:token` and `api.codewhisperer.profile` locally. +7. Verifies `kiro-cli whoami` through the proxy and parses the `Email:` line + when present. +8. Runs `kiro-local-account-importer` dry-run and apply against `http://127.0.0.1:19182`. + Proxy assignment is delegated to the importer, which chooses the least-used + active United States proxy first and uses latency only as a tie-breaker. +9. Writes parsed email into the llm-access Kiro account through the same + `import-auth` path that updates the refreshed social token. +10. Refreshes balance and fails unless the account is `KIRO STUDENT` with at least `1000` usage limit by default. +11. Removes temporary browser profiles and token response files. + +The GitHub script follows the same Kiro/device-code/import/verification path. +Its DevTools helper can submit the GitHub login form when `--github-login` and +`KIRO_GITHUB_PASSWORD` are available, then it waits for 2FA, device +verification, or OAuth consent in the visible browser session. It also assists +Kiro-side `Continue`/`Approve` controls. When `--account-name` is omitted, it +uses a temporary probe account only to refresh balance and identify the +existing account by upstream `user_id`; the probe account is deleted when it is +not automatically removed by duplicate detection. + +## Kiro Admin Lookup Rules + +- Kiro's device authorization API expects `loginProvider: "Github"` for GitHub + login. Do not send the display spelling `GitHub`; the API rejects it. +- Do not assume `GET /admin/kiro-gateway/accounts?limit=10000&offset=0` + returns every account. The admin API caps `limit` server-side, so full-list + scans must follow `has_more` with increasing `offset`. +- For an exact suspected account name, prefer the server-side search first: + `GET /admin/kiro-gateway/accounts?q=`. +- For 401 repair batches, prefer the issue filter: + `GET /admin/kiro-gateway/accounts?issue=auth_401`. +- If a local scan and the admin UI disagree, treat the scan as suspect until + the query is repeated with `q=...` or a verified paginated fetch. + +## Required Options + +- `--email`: Google account email. +- `--account-name`: llm-access Kiro account name. Prefer `kiro--google-social`. +- Password source: `KIRO_GOOGLE_PASSWORD` by default, or interactive hidden prompt. + +For GitHub: + +- `--account-name`: optional. Omit it when refreshing an existing 401 account + and let the script match by upstream `user_id`. Provide it only for explicit + new-account import or exact-name replacement. Prefer + `kiro--github-social` for new accounts. +- `--github-login`: optional GitHub username or email for login prefill. If + omitted and `--account-name` is present, the script may use `--account-name` + as the GitHub login when a password is available. +- GitHub password source: `KIRO_GITHUB_PASSWORD` by default via + `--password-env`. Do not pass passwords on the command line. +- No 2FA option is accepted; use the launched browser for 2FA. + +## Useful Options + +- `--proxy http://127.0.0.1:11111`: override the login proxy. +- `--admin-base-url http://127.0.0.1:19182`: override local mapped llm-access admin API. +- `--replace-account`: delete the exact target account before importing; for + safety this requires `--account-name`. +- `--delete-account-name NAME`: delete an exact mistakenly imported account before the flow. +- `--manual-timeout-seconds 300`: time allowed for manual CAPTCHA/MFA completion if Google requires it. +- `--expect-usage-limit 1000`: required usage limit for verification. +- `--no-expect-student`: allow non-student accounts, but still print the balance. + +For GitHub, `--manual-timeout-seconds` defaults to `600` to allow manual 2FA +and OAuth consent. `--password-env NAME` changes the environment variable used +for GitHub password prefill. + +## Failure Handling + +- If Google shows CAPTCHA or MFA, complete it in the launched isolated browser; the script keeps polling until the manual timeout. +- If GitHub shows login, 2FA, device verification, or OAuth consent, complete it + in the launched isolated browser; the script keeps polling until the manual + timeout. +- If Google shows `Something went wrong` with `Restart`, click `Restart` and continue the same OAuth flow. +- If the import step fails after account creation, query the exact account name before retrying. Do not create a second account name unless the user requests it. +- If balance is not `KIRO STUDENT` or usage limit is below `1000`, report the account as not acceptable and do not hide the failure. +- If a wrong AWS/IDC account was imported, delete that exact account via llm-access admin API, then rerun the social flow. Do not call logout. + +## Verification Commands + +```bash +HTTP_PROXY=http://127.0.0.1:11111 \ +HTTPS_PROXY=http://127.0.0.1:11111 \ +ALL_PROXY=http://127.0.0.1:11111 \ +kiro-cli whoami + +curl -fsS -X POST \ + http://127.0.0.1:19182/admin/kiro-gateway/accounts//balance \ + | jq '{subscription_title, usage_limit, remaining, current_usage, user_id}' +``` diff --git a/skills/kiro-social-onboarder/agents/openai.yaml b/skills/kiro-social-onboarder/agents/openai.yaml new file mode 100644 index 00000000..2d32ff78 --- /dev/null +++ b/skills/kiro-social-onboarder/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Kiro Social Onboarder" + short_description: "Automate Kiro social Google or GitHub login and llm-access import." + default_prompt: "Use Kiro social onboarding to log in a Google or GitHub account through the required proxy, import it into llm-access, and verify student credits." diff --git a/skills/kiro-social-onboarder/scripts/drive_kiro_social_github.mjs b/skills/kiro-social-onboarder/scripts/drive_kiro_social_github.mjs new file mode 100755 index 00000000..645528b7 --- /dev/null +++ b/skills/kiro-social-onboarder/scripts/drive_kiro_social_github.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node + +const port = process.env.KIRO_DEVTOOLS_PORT; +const githubLogin = process.env.KIRO_GITHUB_LOGIN || ""; +const githubPassword = process.env.KIRO_GITHUB_PASSWORD || ""; +const timeoutSeconds = Number(process.env.KIRO_MANUAL_TIMEOUT_SECONDS || "600"); + +if (!port) { + console.error("KIRO_DEVTOOLS_PORT is required"); + process.exit(2); +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function connectPage() { + const deadline = Date.now() + 25_000; + while (Date.now() < deadline) { + try { + const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + const page = pages.find((item) => item.type === "page"); + if (page?.webSocketDebuggerUrl) { + return page; + } + } catch { + // Chrome may still be starting. + } + await sleep(250); + } + throw new Error("Chrome DevTools page target not found"); +} + +const page = await connectPage(); +const ws = new WebSocket(page.webSocketDebuggerUrl); +let nextId = 0; +const pending = new Map(); + +ws.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.id && pending.has(message.id)) { + pending.get(message.id)(message); + pending.delete(message.id); + } +}; + +await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = reject; +}); + +function send(method, params = {}) { + return new Promise((resolve) => { + const id = ++nextId; + pending.set(id, resolve); + ws.send(JSON.stringify({ id, method, params })); + }); +} + +async function evalJs(expression) { + const response = await send("Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (response.exceptionDetails) { + throw new Error(JSON.stringify(response.exceptionDetails)); + } + return response.result?.result?.value; +} + +function jsString(value) { + return JSON.stringify(value); +} + +async function state() { + return await evalJs(`(() => ({ + title: document.title, + url: location.href, + text: document.body ? document.body.innerText.slice(0, 2600) : "", + hasLoginInput: !!document.querySelector('#login_field,input[name="login"],input[name="user_login"],input[type="email"]'), + hasPasswordInput: !!document.querySelector('#password,input[name="password"],input[type="password"]'), + buttons: [...document.querySelectorAll('button,a,[role="button"],input[type="submit"]')] + .map((e) => (e.innerText || e.value || e.getAttribute('aria-label') || '').trim()) + .filter(Boolean) + .slice(0, 80), + }))()`); +} + +async function clickText(label) { + return await evalJs(`(() => { + const target = ${jsString(label)}; + const primary = [...document.querySelectorAll('button,a,[role="button"],input[type="submit"]')]; + const el = primary.find((e) => (e.innerText || e.value || e.getAttribute('aria-label') || '').trim() === target); + if (!el) return false; + el.click(); + return true; + })()`); +} + +async function clickTextContaining(fragment) { + return await evalJs(`(() => { + const target = ${jsString(fragment)}.toLowerCase(); + const primary = [...document.querySelectorAll('button,a,[role="button"],input[type="submit"]')]; + const el = primary.find((e) => ((e.innerText || e.value || e.getAttribute('aria-label') || '').trim().toLowerCase()).includes(target)); + if (!el) return false; + el.click(); + return true; + })()`); +} + +async function setInput(selector, value) { + return await evalJs(`(() => { + const e = document.querySelector(${jsString(selector)}); + if (!e) return false; + e.focus(); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; + setter.call(e, ${jsString(value)}); + e.dispatchEvent(new Event('input', { bubbles: true })); + e.dispatchEvent(new Event('change', { bubbles: true })); + return e.value.length; + })()`); +} + +await send("Runtime.enable"); +await send("Page.enable"); + +const deadline = Date.now() + timeoutSeconds * 1000; +let lastAction = "started"; +let lastManualNoticeAt = 0; +let submittedGithubCredentials = false; + +while (Date.now() < deadline) { + const current = await state(); + const text = current.text || ""; + const lower = text.toLowerCase(); + const url = current.url || ""; + const buttons = current.buttons || []; + + if ( + lower.includes("device authorized") || + lower.includes("authorization complete") || + lower.includes("you may close this window") + ) { + console.log("Browser helper: device authorized"); + ws.close(); + process.exit(0); + } + + if (lower.includes("something went wrong") && buttons.includes("Restart")) { + await clickText("Restart"); + lastAction = "clicked Restart after Kiro error"; + console.log("Browser helper: clicked Restart after Kiro error"); + await sleep(2500); + continue; + } + + if (lower.includes("authorization requested")) { + await clickText("Accept"); + await sleep(300); + const approved = (await clickText("Approve")) || (await clickTextContaining("approve")); + lastAction = `clicked Kiro approval=${approved}`; + console.log("Browser helper: clicked Kiro approval"); + await sleep(1200); + continue; + } + + if (!url.includes("github.com") && buttons.includes("Continue")) { + await clickText("Continue"); + lastAction = "clicked Kiro Continue"; + console.log("Browser helper: clicked Kiro Continue"); + await sleep(2000); + continue; + } + + const githubLoginPage = + url.includes("github.com") && + (url.includes("/login") || + lower.includes("sign in to github") || + lower.includes("username or email address")); + if ( + !submittedGithubCredentials && + githubLogin && + githubPassword && + githubLoginPage && + current.hasLoginInput && + current.hasPasswordInput + ) { + const loginLength = await setInput( + '#login_field,input[name="login"],input[name="user_login"],input[type="email"]', + githubLogin + ); + const passwordLength = await setInput( + '#password,input[name="password"],input[type="password"]', + githubPassword + ); + await sleep(250); + const clicked = (await clickText("Sign in")) || (await clickTextContaining("sign in")); + submittedGithubCredentials = true; + lastAction = `submitted GitHub credentials login_len=${loginLength} password_len=${passwordLength} clicked=${clicked}`; + console.log("Browser helper: submitted GitHub credentials"); + await sleep(3500); + continue; + } + + if ( + url.includes("github.com") || + lower.includes("sign in to github") || + lower.includes("two-factor") || + lower.includes("two factor") || + lower.includes("authentication code") || + lower.includes("verify your identity") || + lower.includes("authorize") + ) { + if (Date.now() - lastManualNoticeAt > 10_000) { + console.log( + "Browser helper: GitHub login/2FA/consent detected; complete it manually in the launched browser" + ); + lastManualNoticeAt = Date.now(); + } + lastAction = "waiting for manual GitHub step"; + await sleep(2000); + continue; + } + + await sleep(1000); +} + +const finalState = await state(); +ws.close(); +console.error( + `Browser helper timed out; lastAction=${lastAction}; title=${finalState.title}; url=${finalState.url}; text=${JSON.stringify((finalState.text || "").slice(0, 500))}` +); +process.exit(1); diff --git a/skills/kiro-social-google-onboarder/scripts/drive_kiro_social_google.mjs b/skills/kiro-social-onboarder/scripts/drive_kiro_social_google.mjs similarity index 100% rename from skills/kiro-social-google-onboarder/scripts/drive_kiro_social_google.mjs rename to skills/kiro-social-onboarder/scripts/drive_kiro_social_google.mjs diff --git a/skills/kiro-social-onboarder/scripts/onboard_kiro_social_github.py b/skills/kiro-social-onboarder/scripts/onboard_kiro_social_github.py new file mode 100755 index 00000000..99540024 --- /dev/null +++ b/skills/kiro-social-onboarder/scripts/onboard_kiro_social_github.py @@ -0,0 +1,852 @@ +#!/usr/bin/env python3 +"""One-shot Kiro social GitHub login and llm-access import. + +The script intentionally avoids `kiro-cli logout`. It edits only known Kiro +auth metadata keys, writes social auth after device approval, and never prints +raw token values. GitHub login can be prefilled from environment-provided +credentials; 2FA and unusual verification stay manual in the launched browser. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import getpass +import json +import os +import shutil +import socket +import sqlite3 +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + + +AUTH_BASE = "https://prod.us-east-1.auth.desktop.kiro.dev" +DEFAULT_PROXY = "http://127.0.0.1:11111" +DEFAULT_ADMIN_BASE = "http://127.0.0.1:19182" +DEFAULT_SQLITE = Path.home() / ".local/share/kiro-cli/data.sqlite3" +NODE_DRIVER = Path(__file__).with_name("drive_kiro_social_github.mjs") +SOCIAL_TOKEN_KEY = "kirocli:social:token" +PROFILE_STATE_KEY = "api.codewhisperer.profile" +DEFAULT_REGION = "us-east-1" +LOCAL_AUTH_KEYS = ( + "kirocli:odic:token", + "kirocli:oidc:token", + "kirocli:odic:device-registration", + "kirocli:oidc:device-registration", + SOCIAL_TOKEN_KEY, +) +LOCAL_STATE_KEYS = ( + PROFILE_STATE_KEY, + "telemetry-cognito-credentials", +) + + +def log(message: str) -> None: + print(message, flush=True) + + +def compact_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def open_json( + method: str, + url: str, + body: dict[str, Any] | None = None, + *, + proxy: str | None = None, + headers: dict[str, str] | None = None, + timeout: float = 30.0, +) -> Any: + data = None if body is None else compact_json(body).encode("utf-8") + merged = {"Accept": "application/json", "User-Agent": "kiro-cli"} + if body is not None: + merged["Content-Type"] = "application/json" + if headers: + merged.update(headers) + req = urllib.request.Request(url, data=data, headers=merged, method=method) + proxy_handler = urllib.request.ProxyHandler( + {"http": proxy, "https": proxy} if proxy else {} + ) + opener = urllib.request.build_opener(proxy_handler) + try: + with opener.open(req, timeout=timeout) as resp: + raw = resp.read() + return json.loads(raw.decode("utf-8")) if raw else None + except urllib.error.HTTPError as exc: + text = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"{method} {url} failed: HTTP {exc.code}: {text[:500]}") from exc + + +def admin_json( + method: str, + base_url: str, + path: str, + body: dict[str, Any] | None = None, + token: str | None = None, +) -> Any: + url = urllib.parse.urljoin(base_url.rstrip("/") + "/", path.lstrip("/")) + headers = {"x-admin-token": token} if token else None + return open_json(method, url, body, headers=headers) + + +def quote_name(name: str) -> str: + return urllib.parse.quote(name, safe="") + + +def field(data: dict[str, Any], *names: str) -> Any: + for name in names: + value = data.get(name) + if isinstance(value, str): + value = value.strip() + if value not in (None, ""): + return value + return None + + +def account_name(account: dict[str, Any]) -> str | None: + value = field(account, "name", "account_name", "id") + return str(value) if value is not None else None + + +def account_user_id(account: dict[str, Any]) -> str | None: + value = field(account, "upstream_user_id") + if value is not None: + return str(value) + balance = account.get("balance") + if isinstance(balance, dict): + value = field(balance, "user_id") + if value is not None: + return str(value) + return None + + +def existing_user_id_map(accounts: list[dict[str, Any]]) -> dict[str, str]: + result: dict[str, str] = {} + for account in accounts: + name = account_name(account) + user_id = account_user_id(account) + if name and user_id: + result.setdefault(user_id, name) + return result + + +def select_existing_account_name_from_probe( + probe_result: dict[str, Any], + accounts: list[dict[str, Any]], + *, + exclude_names: set[str] | None = None, +) -> str | None: + exclude_names = exclude_names or set() + results = probe_result.get("results") + if not isinstance(results, list): + return None + for result in results: + if not isinstance(result, dict): + continue + duplicate_of = field(result, "duplicate_of") + if duplicate_of is not None: + duplicate_name = str(duplicate_of) + if duplicate_name not in exclude_names: + return duplicate_name + user_ids = existing_user_id_map( + [account for account in accounts if account_name(account) not in exclude_names] + ) + for result in results: + if not isinstance(result, dict): + continue + balance = result.get("balance") + if not isinstance(balance, dict): + continue + user_id = field(balance, "user_id") + if user_id is not None and str(user_id) in user_ids: + return user_ids[str(user_id)] + return None + + +def find_account(accounts: list[dict[str, Any]], name: str) -> dict[str, Any] | None: + return next((account for account in accounts if account_name(account) == name), None) + + +def account_has_refreshable_auth_issue(account: dict[str, Any]) -> bool: + issue_kind = field(account, "issue_kind") + if str(issue_kind).lower() == "auth_401": + return True + + messages: list[str] = [] + for key in ("disabled_reason", "issue_summary", "last_error"): + value = field(account, key) + if value is not None: + messages.append(str(value)) + cache = account.get("cache") + if isinstance(cache, dict): + value = field(cache, "error_message", "last_error") + if value is not None: + messages.append(str(value)) + + refreshable_markers = ( + "401", + "unauthorized", + "invalid_refresh_token", + "invalid_grant", + ) + return any( + marker in message.lower() + for message in messages + for marker in refreshable_markers + ) + + +def should_preserve_disabled_state(account: dict[str, Any]) -> bool: + return bool(account.get("disabled", False)) and not account_has_refreshable_auth_issue(account) + + +def build_existing_account_import_body( + current: dict[str, Any], + token_payload: dict[str, Any], + *, + expires_at: str, + source_db_path: Path, + email: str | None = None, +) -> dict[str, Any]: + name = account_name(current) + if not name: + raise RuntimeError("matched Kiro account is missing a name") + access_token = token_payload.get("accessToken") + refresh_token = token_payload.get("refreshToken") + profile_arn = token_payload.get("profileArn") or current.get("profile_arn") + if not access_token or not refresh_token or not profile_arn: + raise RuntimeError("social token response missing required fields") + + effective_email = email.strip() if isinstance(email, str) else None + if not effective_email: + existing_email = field(current, "email") + effective_email = str(existing_email) if existing_email is not None else None + + body: dict[str, Any] = { + "name": name, + "access_token": access_token, + "refresh_token": refresh_token, + "profile_arn": profile_arn, + "expires_at": expires_at, + "auth_method": "social", + "provider": "github", + "region": current.get("region") or DEFAULT_REGION, + "auth_region": current.get("auth_region") or current.get("region") or DEFAULT_REGION, + "api_region": current.get("api_region") or current.get("region") or DEFAULT_REGION, + "source_db_path": str(source_db_path), + "last_imported_at": int(time.time() * 1000), + "disabled": should_preserve_disabled_state(current), + } + if effective_email: + body["email"] = effective_email + for key in ( + "subscription_title", + "machine_id", + "kiro_channel_max_concurrency", + "kiro_channel_min_start_interval_ms", + "minimum_remaining_credits_before_block", + "manual_usage_limit", + "pool_strategy", + ): + if current.get(key) is not None: + body[key] = current[key] + return body + + +def check_proxy(proxy: str) -> None: + req = urllib.request.Request("https://github.com/login", method="GET") + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({"http": proxy, "https": proxy}) + ) + with opener.open(req, timeout=20) as resp: + if resp.status not in (200, 302): + raise RuntimeError(f"proxy probe returned HTTP {resp.status}") + + +def backup_and_clean_sqlite(path: Path) -> Path: + if not path.is_file(): + raise FileNotFoundError(f"Kiro SQLite not found: {path}") + backup_dir = path.parent / "backups" + backup_dir.mkdir(parents=True, exist_ok=True) + stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + backup = backup_dir / f"{path.name}.before-social-github-onboard-{stamp}" + shutil.copy2(path, backup) + + conn = sqlite3.connect(path) + try: + conn.executemany("DELETE FROM auth_kv WHERE key = ?", [(key,) for key in LOCAL_AUTH_KEYS]) + conn.executemany("DELETE FROM state WHERE key = ?", [(key,) for key in LOCAL_STATE_KEYS]) + conn.commit() + finally: + conn.close() + return backup + + +def write_social_token(path: Path, payload: dict[str, Any]) -> dict[str, Any]: + access_token = payload.get("accessToken") + refresh_token = payload.get("refreshToken") + profile_arn = payload.get("profileArn") + if not access_token or not refresh_token or not profile_arn: + raise RuntimeError("social token response missing required fields") + expires_at = ( + dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=1) + ).isoformat().replace("+00:00", "Z") + token = { + "access_token": access_token, + "refresh_token": refresh_token, + "expires_at": expires_at, + "provider": "github", + "profile_arn": profile_arn, + } + profile = {"arn": profile_arn, "profile_name": "Social_Default_Profile"} + conn = sqlite3.connect(path) + try: + conn.execute( + "INSERT OR REPLACE INTO auth_kv(key, value) VALUES (?, ?)", + (SOCIAL_TOKEN_KEY, compact_json(token)), + ) + conn.execute( + "INSERT OR REPLACE INTO state(key, value) VALUES (?, ?)", + (PROFILE_STATE_KEY, compact_json(profile)), + ) + conn.commit() + finally: + conn.close() + return { + "provider": "github", + "profile_arn": profile_arn, + "access_token_len": len(access_token), + "refresh_token_len": len(refresh_token), + "expires_at": expires_at, + } + + +def find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def chrome_binary(explicit: str | None) -> str: + if explicit: + return explicit + for name in ("google-chrome", "chromium", "chromium-browser"): + found = shutil.which(name) + if found: + return found + raise RuntimeError("Chrome/Chromium binary not found") + + +def wait_http_json(url: str, timeout: float = 20.0) -> Any: + deadline = time.monotonic() + timeout + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + while time.monotonic() < deadline: + try: + with opener.open(url, timeout=2) as resp: + return json.loads(resp.read().decode("utf-8")) + except Exception: + time.sleep(0.25) + raise RuntimeError(f"timed out waiting for {url}") + + +def start_device_flow_helper( + args: argparse.Namespace, + port: int, + github_password: str | None = None, +) -> subprocess.Popen[Any]: + if not NODE_DRIVER.is_file(): + raise FileNotFoundError(f"Node DevTools driver not found: {NODE_DRIVER}") + if not shutil.which("node"): + raise RuntimeError("node is required for browser automation") + env = os.environ.copy() + env.update( + { + "KIRO_DEVTOOLS_PORT": str(port), + "KIRO_MANUAL_TIMEOUT_SECONDS": str(args.manual_timeout_seconds), + } + ) + github_login = resolve_github_login(args) + if github_login and github_password: + env["KIRO_GITHUB_LOGIN"] = github_login + env["KIRO_GITHUB_PASSWORD"] = github_password + return subprocess.Popen( + ["node", str(NODE_DRIVER)], + env=env, + stdout=None, + stderr=None, + ) + + +def stop_device_flow_helper(proc: subprocess.Popen[Any] | None) -> None: + if not proc or proc.poll() is not None: + return + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + +def start_device_authorization(proxy: str, client_id: str) -> dict[str, Any]: + return open_json( + "POST", + f"{AUTH_BASE}/oauth/device/authorization", + {"clientId": client_id, "loginProvider": "Github"}, + proxy=proxy, + ) + + +def poll_device_token(proxy: str, client_id: str, device_code: str, timeout_seconds: int) -> dict[str, Any]: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + try: + payload = open_json( + "POST", + f"{AUTH_BASE}/oauth/device/poll", + {"clientId": client_id, "deviceCode": device_code}, + proxy=proxy, + ) + except RuntimeError as exc: + if "AuthorizationPending" in str(exc) or "authorization" in str(exc).lower(): + time.sleep(5) + continue + raise + if isinstance(payload, dict) and payload.get("accessToken") and payload.get("refreshToken"): + return payload + time.sleep(5) + raise RuntimeError("timed out polling Kiro social token") + + +def launch_chrome(args: argparse.Namespace, url: str) -> tuple[subprocess.Popen[Any], int, str]: + port = args.debug_port or find_free_port() + profile_dir = args.chrome_profile or tempfile.mkdtemp(prefix="kiro-social-github-") + cmd = [ + chrome_binary(args.chrome_bin), + f"--user-data-dir={profile_dir}", + f"--proxy-server={args.proxy}", + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + "--disable-gpu", + "--disable-software-rasterizer", + "--remote-debugging-address=127.0.0.1", + f"--remote-debugging-port={port}", + url, + ] + proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return proc, port, profile_dir + + +def wait_for_page_target(port: int) -> None: + pages = wait_http_json(f"http://127.0.0.1:{port}/json/list", timeout=25) + page = next((item for item in pages if item.get("type") == "page"), None) + if not page: + raise RuntimeError("Chrome DevTools page target not found") + + +def run_kiro_whoami(args: argparse.Namespace) -> str: + env = os.environ.copy() + env.update({"HTTP_PROXY": args.proxy, "HTTPS_PROXY": args.proxy, "ALL_PROXY": args.proxy}) + result = subprocess.run( + [args.kiro_cli, "whoami"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(f"kiro-cli whoami failed: {result.stdout.strip()}") + return result.stdout.strip() + + +def parse_whoami_email(output: str) -> str | None: + for line in output.splitlines(): + key, separator, value = line.partition(":") + if separator and key.strip().lower() == "email": + email = value.strip() + return email or None + return None + + +def resolve_github_login(args: argparse.Namespace) -> str | None: + login = field(vars(args), "github_login") + if login is not None: + return str(login) + if args.account_name: + return str(args.account_name) + return None + + +def resolve_github_password(args: argparse.Namespace, github_login: str | None) -> str | None: + password = os.environ.get(args.password_env) + if password: + return password + if github_login and sys.stdin.isatty(): + password = getpass.getpass(f"GitHub password for {github_login}: ") + if not password: + raise SystemExit("empty GitHub password") + return password + return None + + +def importer_path() -> Path: + return ( + Path(__file__).resolve().parents[2] + / "kiro-local-account-importer/scripts/import_kiro_accounts.py" + ) + + +def run_importer( + args: argparse.Namespace, + *, + apply: bool, + account_name_override: str | None = None, + allow_failure: bool = False, +) -> dict[str, Any]: + account_name_value = account_name_override or args.account_name + if not account_name_value: + raise RuntimeError("account name is required for importer invocation") + cmd = [ + sys.executable, + str(importer_path()), + "--admin-base-url", + args.admin_base_url, + "--sqlite-file", + str(args.sqlite_file), + "--account-name", + account_name_value, + "--seed", + str(args.seed), + ] + if args.admin_token: + cmd.extend(["--admin-token", args.admin_token]) + if apply: + cmd.append("--apply") + result = subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if result.returncode != 0: + if allow_failure: + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError: + payload = {"raw_output": result.stdout} + payload["returncode"] = result.returncode + return payload + raise RuntimeError(f"importer failed with code {result.returncode}:\n{result.stdout}") + payload = json.loads(result.stdout) + payload["returncode"] = result.returncode + return payload + + +def fetch_kiro_accounts(base_url: str, token: str | None) -> list[dict[str, Any]]: + accounts: list[dict[str, Any]] = [] + offset = 0 + limit = 200 + while True: + payload = admin_json( + "GET", + base_url, + f"/admin/kiro-gateway/accounts?limit={limit}&offset={offset}", + token=token, + ) + page_accounts = payload.get("accounts", []) if isinstance(payload, dict) else [] + batch = [account for account in page_accounts if isinstance(account, dict)] + accounts.extend(batch) + if not isinstance(payload, dict) or not payload.get("has_more") or not batch: + return accounts + offset += len(batch) + + +def temporary_probe_account_name() -> str: + return f"kiro-refresh-probe-{int(time.time())}-{os.getpid()}" + + +def delete_account(base_url: str, name: str, token: str | None) -> None: + try: + admin_json("DELETE", base_url, f"/admin/kiro-gateway/accounts/{quote_name(name)}", token=token) + log(f"Deleted llm-access Kiro account: {name}") + except Exception as exc: + log(f"Delete skipped/failed for {name}: {exc}") + + +def refresh_balance(args: argparse.Namespace) -> dict[str, Any]: + if not args.account_name: + raise RuntimeError("account name is required for balance refresh") + return admin_json( + "POST", + args.admin_base_url, + f"/admin/kiro-gateway/accounts/{quote_name(args.account_name)}/balance", + token=args.admin_token, + ) + + +def refresh_balance_for_account(args: argparse.Namespace, account_name_value: str) -> dict[str, Any]: + return admin_json( + "POST", + args.admin_base_url, + f"/admin/kiro-gateway/accounts/{quote_name(account_name_value)}/balance", + token=args.admin_token, + ) + + +def update_existing_account_from_token( + args: argparse.Namespace, + target_name: str, + token_payload: dict[str, Any], + expires_at: str, + email: str | None = None, +) -> dict[str, Any]: + accounts = fetch_kiro_accounts(args.admin_base_url, args.admin_token) + current = find_account(accounts, target_name) + if current is None: + raise RuntimeError(f"matched Kiro account not found: {target_name}") + body = build_existing_account_import_body( + current, + token_payload, + expires_at=expires_at, + source_db_path=args.sqlite_file.expanduser(), + email=email, + ) + saved = admin_json( + "POST", + args.admin_base_url, + "/admin/kiro-gateway/accounts/import-auth", + body=body, + token=args.admin_token, + ) + proxy_mode = current.get("proxy_mode") + proxy_config_id = current.get("proxy_config_id") + patched = None + if proxy_mode == "fixed" and proxy_config_id: + patched = admin_json( + "PATCH", + args.admin_base_url, + f"/admin/kiro-gateway/accounts/{quote_name(target_name)}", + body={"proxy_mode": "fixed", "proxy_config_id": proxy_config_id}, + token=args.admin_token, + ) + elif proxy_mode in ("inherit", "none"): + patched = admin_json( + "PATCH", + args.admin_base_url, + f"/admin/kiro-gateway/accounts/{quote_name(target_name)}", + body={"proxy_mode": proxy_mode}, + token=args.admin_token, + ) + balance = refresh_balance_for_account(args, target_name) + return { + "account_name": target_name, + "saved_name": saved.get("name") if isinstance(saved, dict) else target_name, + "patched_proxy": patched is not None, + "proxy_mode": proxy_mode, + "proxy_config_id": proxy_config_id, + "proxy_config_name": current.get("effective_proxy_config_name"), + "balance": balance, + } + + +def auto_refresh_existing_account( + args: argparse.Namespace, + token_payload: dict[str, Any], + expires_at: str, + email: str | None = None, +) -> dict[str, Any]: + probe_name = temporary_probe_account_name() + log(f"Probing refreshed credentials with temporary account: {probe_name}") + dry_run = run_importer(args, apply=False, account_name_override=probe_name) + log("Probe importer dry-run:") + log(json.dumps(dry_run, ensure_ascii=False, indent=2)) + probe = run_importer( + args, + apply=True, + account_name_override=probe_name, + allow_failure=True, + ) + log("Probe importer apply:") + log(json.dumps(probe, ensure_ascii=False, indent=2)) + accounts = fetch_kiro_accounts(args.admin_base_url, args.admin_token) + target_name = select_existing_account_name_from_probe( + probe, + accounts, + exclude_names={probe_name}, + ) + if target_name is None: + delete_account(args.admin_base_url, probe_name, args.admin_token) + raise RuntimeError("refreshed credentials did not match an existing Kiro account") + if find_account(fetch_kiro_accounts(args.admin_base_url, args.admin_token), probe_name): + delete_account(args.admin_base_url, probe_name, args.admin_token) + updated = update_existing_account_from_token( + args, + target_name, + token_payload, + expires_at, + email=email, + ) + updated["probe_name"] = probe_name + return updated + + +def validate_balance(args: argparse.Namespace, balance: dict[str, Any]) -> None: + title = str(balance.get("subscription_title") or "") + limit = float(balance.get("usage_limit") or 0) + if args.expect_student and "STUDENT" not in title.upper(): + raise RuntimeError(f"expected KIRO STUDENT, got {title!r}") + if limit < args.expect_usage_limit: + raise RuntimeError(f"expected usage_limit >= {args.expect_usage_limit}, got {limit}") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--account-name", + help=( + "Explicit llm-access account name to import. Omit it to auto-match " + "the refreshed credentials to an existing account by upstream user_id." + ), + ) + parser.add_argument("--proxy", default=DEFAULT_PROXY) + parser.add_argument("--admin-base-url", default=DEFAULT_ADMIN_BASE) + parser.add_argument("--admin-token") + parser.add_argument("--sqlite-file", type=Path, default=DEFAULT_SQLITE) + parser.add_argument("--kiro-cli", default="kiro-cli") + parser.add_argument("--client-id", default="kiro-cli") + parser.add_argument( + "--github-login", + help=( + "GitHub username or email for automatic login-page prefill. " + "Defaults to --account-name when a password is available." + ), + ) + parser.add_argument("--password-env", default="KIRO_GITHUB_PASSWORD") + parser.add_argument("--replace-account", action="store_true") + parser.add_argument("--delete-account-name", action="append", default=[]) + parser.add_argument("--manual-timeout-seconds", type=int, default=600) + parser.add_argument("--token-poll-timeout-seconds", type=int, default=600) + parser.add_argument("--seed", type=int, default=745) + parser.add_argument("--expect-usage-limit", type=float, default=1000.0) + parser.add_argument("--no-expect-student", dest="expect_student", action="store_false") + parser.set_defaults(expect_student=True) + parser.add_argument("--chrome-bin") + parser.add_argument("--chrome-profile") + parser.add_argument("--debug-port", type=int) + parser.add_argument("--keep-browser", action="store_true") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + if args.replace_account and not args.account_name: + raise SystemExit("--replace-account requires --account-name") + github_login = resolve_github_login(args) + github_password = resolve_github_password(args, github_login) + + log("Checking proxy...") + check_proxy(args.proxy) + + for name in args.delete_account_name: + delete_account(args.admin_base_url, name, args.admin_token) + if args.replace_account: + delete_account(args.admin_base_url, args.account_name, args.admin_token) + + backup = backup_and_clean_sqlite(args.sqlite_file.expanduser()) + log(f"Backed up and cleaned local Kiro auth metadata: {backup}") + + auth = start_device_authorization(args.proxy, args.client_id) + device_code = auth["deviceCode"] + verify_url = auth["verificationUriComplete"] + log(f"Started Kiro social GitHub device flow. User code: {auth.get('userCode')}") + log("Complete GitHub login, 2FA, and consent in the launched browser when prompted.") + + proc: subprocess.Popen[Any] | None = None + helper: subprocess.Popen[Any] | None = None + profile_dir: str | None = None + try: + proc, port, profile_dir = launch_chrome(args, verify_url) + wait_for_page_target(port) + helper = start_device_flow_helper(args, port, github_password) + token_payload = poll_device_token( + args.proxy, args.client_id, device_code, args.token_poll_timeout_seconds + ) + written = write_social_token(args.sqlite_file.expanduser(), token_payload) + log( + "Wrote local social token: " + + compact_json({key: written[key] for key in written if not key.endswith("_len")}) + ) + finally: + stop_device_flow_helper(helper) + if proc and not args.keep_browser: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + if profile_dir and not args.keep_browser and not args.chrome_profile: + shutil.rmtree(profile_dir, ignore_errors=True) + + whoami = run_kiro_whoami(args) + log("kiro-cli whoami:") + log(whoami) + whoami_email = parse_whoami_email(whoami) + if whoami_email: + log(f"Detected Kiro account email: {whoami_email}") + + if args.account_name: + dry_run = run_importer(args, apply=False) + log("Importer dry-run:") + log(json.dumps(dry_run, ensure_ascii=False, indent=2)) + + applied = run_importer(args, apply=True) + log("Importer apply:") + log(json.dumps(applied, ensure_ascii=False, indent=2)) + target_name = args.account_name + if whoami_email: + refreshed = update_existing_account_from_token( + args, + target_name, + token_payload, + written["expires_at"], + email=whoami_email, + ) + log("Updated existing Kiro account email/token:") + log(json.dumps(refreshed, ensure_ascii=False, indent=2)) + balance = refreshed["balance"] + else: + balance = refresh_balance(args) + else: + refreshed = auto_refresh_existing_account( + args, + token_payload, + written["expires_at"], + email=whoami_email, + ) + target_name = str(refreshed["account_name"]) + balance = refreshed["balance"] + log("Updated existing Kiro account:") + log(json.dumps(refreshed, ensure_ascii=False, indent=2)) + + validate_balance(args, balance) + summary = { + "account_name": target_name, + "subscription_title": balance.get("subscription_title"), + "usage_limit": balance.get("usage_limit"), + "remaining": balance.get("remaining"), + "current_usage": balance.get("current_usage"), + "user_id": balance.get("user_id"), + "email": whoami_email, + } + log("Final balance:") + log(json.dumps(summary, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/skills/kiro-social-google-onboarder/scripts/onboard_kiro_social_google.py b/skills/kiro-social-onboarder/scripts/onboard_kiro_social_google.py similarity index 100% rename from skills/kiro-social-google-onboarder/scripts/onboard_kiro_social_google.py rename to skills/kiro-social-onboarder/scripts/onboard_kiro_social_google.py diff --git a/skills/kiro-social-onboarder/tests/test_onboard_kiro_social_github.py b/skills/kiro-social-onboarder/tests/test_onboard_kiro_social_github.py new file mode 100644 index 00000000..3b699810 --- /dev/null +++ b/skills/kiro-social-onboarder/tests/test_onboard_kiro_social_github.py @@ -0,0 +1,424 @@ +import importlib.util +import json +import sqlite3 +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = ( + Path(__file__).resolve().parents[1] + / "scripts" + / "onboard_kiro_social_github.py" +) +SPEC = importlib.util.spec_from_file_location("onboard_kiro_social_github", SCRIPT) +module = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = module +SPEC.loader.exec_module(module) + + +class KiroSocialGithubOnboarderTest(unittest.TestCase): + def test_device_authorization_requests_github_provider(self): + calls = [] + + def fake_open_json(method, url, body=None, **kwargs): + calls.append((method, url, body, kwargs)) + return { + "deviceCode": "device-code", + "verificationUriComplete": "https://kiro.example/verify", + } + + original = module.open_json + module.open_json = fake_open_json + try: + result = module.start_device_authorization("http://127.0.0.1:11111", "kiro-cli") + finally: + module.open_json = original + + self.assertEqual(result["deviceCode"], "device-code") + self.assertEqual(len(calls), 1) + method, url, body, kwargs = calls[0] + self.assertEqual(method, "POST") + self.assertTrue(url.endswith("/oauth/device/authorization")) + self.assertEqual(body, {"clientId": "kiro-cli", "loginProvider": "Github"}) + self.assertEqual(kwargs["proxy"], "http://127.0.0.1:11111") + + def test_write_social_token_marks_provider_github_without_returning_raw_tokens(self): + with tempfile.TemporaryDirectory() as tmp: + db_path = Path(tmp) / "data.sqlite3" + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE auth_kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)") + conn.execute("CREATE TABLE state (key TEXT PRIMARY KEY, value TEXT NOT NULL)") + conn.commit() + conn.close() + + written = module.write_social_token( + db_path, + { + "accessToken": "access-token-value", + "refreshToken": "refresh-token-value", + "profileArn": "arn:aws:codewhisperer:us-east-1:123:profile/test", + }, + ) + + self.assertEqual(written["provider"], "github") + self.assertEqual(written["profile_arn"], "arn:aws:codewhisperer:us-east-1:123:profile/test") + self.assertNotIn("access-token-value", json.dumps(written)) + self.assertNotIn("refresh-token-value", json.dumps(written)) + + conn = sqlite3.connect(db_path) + try: + row = conn.execute( + "SELECT value FROM auth_kv WHERE key = ?", + (module.SOCIAL_TOKEN_KEY,), + ).fetchone() + finally: + conn.close() + self.assertIsNotNone(row) + token = json.loads(row[0]) + self.assertEqual(token["provider"], "github") + self.assertEqual(token["access_token"], "access-token-value") + self.assertEqual(token["refresh_token"], "refresh-token-value") + + def test_parse_args_accepts_optional_github_login_without_password_argument(self): + args = module.parse_args(["--account-name", "kiro-gh", "--github-login", "gfryuuu"]) + + self.assertEqual(args.account_name, "kiro-gh") + self.assertEqual(args.github_login, "gfryuuu") + self.assertEqual(args.password_env, "KIRO_GITHUB_PASSWORD") + self.assertEqual(args.manual_timeout_seconds, 600) + + def test_parse_args_allows_omitted_account_name_for_auto_match(self): + args = module.parse_args([]) + + self.assertIsNone(args.account_name) + self.assertFalse(args.replace_account) + + def test_browser_helper_starts_without_provider_credentials_by_default(self): + calls = [] + + class FakeProcess: + pass + + def fake_which(name): + return "/usr/bin/node" if name == "node" else None + + def fake_popen(cmd, env, **kwargs): + calls.append((cmd, env, kwargs)) + return FakeProcess() + + args = module.parse_args(["--account-name", "kiro-gh"]) + original_which = module.shutil.which + original_popen = module.subprocess.Popen + module.shutil.which = fake_which + module.subprocess.Popen = fake_popen + try: + process = module.start_device_flow_helper(args, 9222) + finally: + module.shutil.which = original_which + module.subprocess.Popen = original_popen + + self.assertIsInstance(process, FakeProcess) + self.assertEqual(len(calls), 1) + cmd, env, _kwargs = calls[0] + self.assertEqual(cmd, ["node", str(module.NODE_DRIVER)]) + self.assertEqual(env["KIRO_DEVTOOLS_PORT"], "9222") + self.assertEqual(env["KIRO_MANUAL_TIMEOUT_SECONDS"], "600") + self.assertNotIn("KIRO_GOOGLE_PASSWORD", env) + self.assertNotIn("KIRO_GITHUB_PASSWORD", env) + self.assertNotIn("KIRO_GITHUB_LOGIN", env) + + def test_browser_helper_passes_github_login_and_password_via_environment_only(self): + calls = [] + + class FakeProcess: + pass + + def fake_which(name): + return "/usr/bin/node" if name == "node" else None + + def fake_popen(cmd, env, **kwargs): + calls.append((cmd, env, kwargs)) + return FakeProcess() + + args = module.parse_args( + [ + "--account-name", + "kiro-gh", + "--github-login", + "gfryuuu", + "--password-env", + "KIRO_TEST_GITHUB_PASSWORD", + ] + ) + original_which = module.shutil.which + original_popen = module.subprocess.Popen + old_password = module.os.environ.get("KIRO_TEST_GITHUB_PASSWORD") + module.shutil.which = fake_which + module.subprocess.Popen = fake_popen + module.os.environ["KIRO_TEST_GITHUB_PASSWORD"] = "secret-password" + try: + password = module.resolve_github_password(args, "gfryuuu") + process = module.start_device_flow_helper(args, 9222, password) + finally: + module.shutil.which = original_which + module.subprocess.Popen = original_popen + if old_password is None: + module.os.environ.pop("KIRO_TEST_GITHUB_PASSWORD", None) + else: + module.os.environ["KIRO_TEST_GITHUB_PASSWORD"] = old_password + + self.assertIsInstance(process, FakeProcess) + self.assertEqual(len(calls), 1) + cmd, env, _kwargs = calls[0] + self.assertEqual(cmd, ["node", str(module.NODE_DRIVER)]) + self.assertNotIn("secret-password", cmd) + self.assertEqual(env["KIRO_GITHUB_LOGIN"], "gfryuuu") + self.assertEqual(env["KIRO_GITHUB_PASSWORD"], "secret-password") + + def test_fetch_kiro_accounts_paginates_past_server_limit(self): + calls = [] + + def fake_admin_json(method, base_url, path, body=None, token=None): + calls.append((method, base_url, path, body, token)) + if "offset=0" in path: + return { + "accounts": [{"name": "first"}], + "limit": 1, + "offset": 0, + "has_more": True, + } + if "offset=1" in path: + return { + "accounts": [{"name": "gfryuuu"}], + "limit": 1, + "offset": 1, + "has_more": False, + } + self.fail(f"unexpected path: {path}") + + original = module.admin_json + module.admin_json = fake_admin_json + try: + accounts = module.fetch_kiro_accounts("http://admin", "token") + finally: + module.admin_json = original + + self.assertEqual([account["name"] for account in accounts], ["first", "gfryuuu"]) + self.assertEqual(len(calls), 2) + + def test_select_existing_account_prefers_importer_duplicate(self): + probe = { + "results": [ + { + "name": "kiro-refresh-probe-1", + "duplicate_user_id": "user-1", + "duplicate_of": "existing-kiro", + } + ] + } + + matched = module.select_existing_account_name_from_probe(probe, []) + + self.assertEqual(matched, "existing-kiro") + + def test_select_existing_account_matches_balance_user_id(self): + probe = { + "results": [ + { + "name": "kiro-refresh-probe-1", + "validated": True, + "balance": {"user_id": "user-2"}, + } + ] + } + accounts = [ + {"name": "other", "balance": {"user_id": "user-1"}}, + {"name": "target", "upstream_user_id": "user-2"}, + ] + + matched = module.select_existing_account_name_from_probe(probe, accounts) + + self.assertEqual(matched, "target") + + def test_select_existing_account_ignores_probe_account(self): + probe = { + "results": [ + { + "name": "kiro-refresh-probe-1", + "validated": True, + "balance": {"user_id": "user-2"}, + } + ] + } + accounts = [ + {"name": "kiro-refresh-probe-1", "upstream_user_id": "user-2"}, + ] + + matched = module.select_existing_account_name_from_probe( + probe, + accounts, + exclude_names={"kiro-refresh-probe-1"}, + ) + + self.assertIsNone(matched) + + def test_select_existing_account_ignores_probe_duplicate(self): + probe = { + "results": [ + { + "name": "kiro-refresh-probe-1", + "duplicate_of": "kiro-refresh-probe-1", + } + ] + } + + matched = module.select_existing_account_name_from_probe( + probe, + [], + exclude_names={"kiro-refresh-probe-1"}, + ) + + self.assertIsNone(matched) + + def test_parse_whoami_email_extracts_email(self): + output = """Logged in with GitHub +Email: unsmore@utexas.edu +User ID: d-123 +""" + + self.assertEqual(module.parse_whoami_email(output), "unsmore@utexas.edu") + + def test_parse_whoami_email_returns_none_when_absent(self): + output = """Logged in with GitHub +User ID: d-123 +""" + + self.assertIsNone(module.parse_whoami_email(output)) + + def test_build_existing_account_import_body_preserves_settings(self): + current = { + "name": "existing-kiro", + "auth_method": "social", + "provider": "github", + "profile_arn": "old-profile", + "region": "us-east-1", + "auth_region": "us-east-1", + "api_region": "us-east-1", + "kiro_channel_max_concurrency": 4, + "kiro_channel_min_start_interval_ms": 250, + "minimum_remaining_credits_before_block": 10.0, + "manual_usage_limit": 900.0, + "pool_strategy": "balanced", + "disabled": False, + "subscription_title": "KIRO STUDENT", + "source_db_path": "/old.sqlite3", + } + token_payload = { + "accessToken": "new-access", + "refreshToken": "new-refresh", + "profileArn": "new-profile", + } + + body = module.build_existing_account_import_body( + current, + token_payload, + expires_at="2030-01-01T00:00:00Z", + source_db_path=Path("/tmp/data.sqlite3"), + ) + + self.assertEqual(body["name"], "existing-kiro") + self.assertEqual(body["access_token"], "new-access") + self.assertEqual(body["refresh_token"], "new-refresh") + self.assertEqual(body["profile_arn"], "new-profile") + self.assertEqual(body["provider"], "github") + self.assertEqual(body["kiro_channel_max_concurrency"], 4) + self.assertEqual(body["kiro_channel_min_start_interval_ms"], 250) + self.assertEqual(body["minimum_remaining_credits_before_block"], 10.0) + self.assertEqual(body["manual_usage_limit"], 900.0) + self.assertEqual(body["pool_strategy"], "balanced") + self.assertEqual(body["source_db_path"], "/tmp/data.sqlite3") + self.assertFalse(body["disabled"]) + + def test_build_existing_account_import_body_records_whoami_email(self): + current = {"name": "existing-kiro"} + token_payload = { + "accessToken": "new-access", + "refreshToken": "new-refresh", + "profileArn": "new-profile", + } + + body = module.build_existing_account_import_body( + current, + token_payload, + expires_at="2030-01-01T00:00:00Z", + source_db_path=Path("/tmp/data.sqlite3"), + email="unsmore@utexas.edu", + ) + + self.assertEqual(body["email"], "unsmore@utexas.edu") + + def test_build_existing_account_import_body_preserves_existing_email_without_override(self): + current = {"name": "existing-kiro", "email": "existing@example.edu"} + token_payload = { + "accessToken": "new-access", + "refreshToken": "new-refresh", + "profileArn": "new-profile", + } + + body = module.build_existing_account_import_body( + current, + token_payload, + expires_at="2030-01-01T00:00:00Z", + source_db_path=Path("/tmp/data.sqlite3"), + ) + + self.assertEqual(body["email"], "existing@example.edu") + + def test_build_existing_account_import_body_reactivates_refresh_token_failures(self): + current = { + "name": "existing-kiro", + "disabled": True, + "disabled_reason": "invalid_refresh_token", + } + token_payload = { + "accessToken": "new-access", + "refreshToken": "new-refresh", + "profileArn": "new-profile", + } + + body = module.build_existing_account_import_body( + current, + token_payload, + expires_at="2030-01-01T00:00:00Z", + source_db_path=Path("/tmp/data.sqlite3"), + ) + + self.assertFalse(body["disabled"]) + + def test_build_existing_account_import_body_preserves_manual_disabled(self): + current = { + "name": "existing-kiro", + "disabled": True, + "disabled_reason": "manually disabled for maintenance", + } + token_payload = { + "accessToken": "new-access", + "refreshToken": "new-refresh", + "profileArn": "new-profile", + } + + body = module.build_existing_account_import_body( + current, + token_payload, + expires_at="2030-01-01T00:00:00Z", + source_db_path=Path("/tmp/data.sqlite3"), + ) + + self.assertTrue(body["disabled"]) + + +if __name__ == "__main__": + unittest.main()