dsh-subscriptions 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -0
  3. package/cordis.patch.yml +12 -0
  4. package/lib/auth/claude-code-creds.d.ts +23 -0
  5. package/lib/auth/claude-code-creds.js +172 -0
  6. package/lib/auth/device-flow.d.ts +55 -0
  7. package/lib/auth/device-flow.js +177 -0
  8. package/lib/auth/jwt.d.ts +10 -0
  9. package/lib/auth/jwt.js +25 -0
  10. package/lib/auth/oauth-flow.d.ts +91 -0
  11. package/lib/auth/oauth-flow.js +227 -0
  12. package/lib/auth/pkce.d.ts +31 -0
  13. package/lib/auth/pkce.js +35 -0
  14. package/lib/auth/rpc.d.ts +209 -0
  15. package/lib/auth/rpc.js +386 -0
  16. package/lib/auth/store.d.ts +196 -0
  17. package/lib/auth/store.js +468 -0
  18. package/lib/client/CursorCard.d.ts +9 -0
  19. package/lib/client/CursorCard.js +113 -0
  20. package/lib/client/ExternalUsageCards.d.ts +9 -0
  21. package/lib/client/ExternalUsageCards.js +72 -0
  22. package/lib/client/ImageGallery.d.ts +54 -0
  23. package/lib/client/ImageGallery.js +112 -0
  24. package/lib/client/ImageGenerateToolview.d.ts +48 -0
  25. package/lib/client/ImageGenerateToolview.js +133 -0
  26. package/lib/client/ProviderAccountManager.d.ts +13 -0
  27. package/lib/client/ProviderAccountManager.js +147 -0
  28. package/lib/client/ProviderModelEditor.d.ts +39 -0
  29. package/lib/client/ProviderModelEditor.js +107 -0
  30. package/lib/client/SpeedSelect.d.ts +71 -0
  31. package/lib/client/SpeedSelect.js +177 -0
  32. package/lib/client/SubscriptionUsageBadge.d.ts +116 -0
  33. package/lib/client/SubscriptionUsageBadge.js +543 -0
  34. package/lib/client/SubscriptionsSection.d.ts +140 -0
  35. package/lib/client/SubscriptionsSection.js +544 -0
  36. package/lib/client/UsageBadgeDisplaySetting.d.ts +7 -0
  37. package/lib/client/UsageBadgeDisplaySetting.js +36 -0
  38. package/lib/client/VideoGenerateToolview.d.ts +42 -0
  39. package/lib/client/VideoGenerateToolview.js +175 -0
  40. package/lib/client/account-preferences.d.ts +24 -0
  41. package/lib/client/account-preferences.js +21 -0
  42. package/lib/client/fast-command.d.ts +2 -0
  43. package/lib/client/fast-command.js +4 -0
  44. package/lib/client/host-contract.d.ts +90 -0
  45. package/lib/client/host-contract.js +101 -0
  46. package/lib/client/host-icons.d.ts +24 -0
  47. package/lib/client/host-icons.js +21 -0
  48. package/lib/client/index.d.ts +27 -0
  49. package/lib/client/index.js +136 -0
  50. package/lib/client/locales.d.ts +297 -0
  51. package/lib/client/locales.js +295 -0
  52. package/lib/client/subscription-card-styles.d.ts +3 -0
  53. package/lib/client/subscription-card-styles.js +53 -0
  54. package/lib/client/subscriptions-rpc.d.ts +14 -0
  55. package/lib/client/subscriptions-rpc.js +34 -0
  56. package/lib/client/usage-badge-preferences.d.ts +11 -0
  57. package/lib/client/usage-badge-preferences.js +41 -0
  58. package/lib/client.js +4472 -0
  59. package/lib/client.js.map +1 -0
  60. package/lib/compat.d.ts +36 -0
  61. package/lib/compat.js +20 -0
  62. package/lib/http.d.ts +6 -0
  63. package/lib/http.js +15 -0
  64. package/lib/index.d.ts +80 -0
  65. package/lib/index.js +14018 -0
  66. package/lib/model-defaults.d.ts +23 -0
  67. package/lib/model-defaults.js +237 -0
  68. package/lib/provider-settings.d.ts +39 -0
  69. package/lib/provider-settings.js +151 -0
  70. package/lib/providers/account-preferences.d.ts +41 -0
  71. package/lib/providers/account-preferences.js +153 -0
  72. package/lib/providers/accounts.d.ts +112 -0
  73. package/lib/providers/accounts.js +142 -0
  74. package/lib/providers/antigravity-oauth-client.d.ts +7 -0
  75. package/lib/providers/antigravity-oauth-client.js +7 -0
  76. package/lib/providers/antigravity.d.ts +110 -0
  77. package/lib/providers/antigravity.js +516 -0
  78. package/lib/providers/catalog-store.d.ts +48 -0
  79. package/lib/providers/catalog-store.js +243 -0
  80. package/lib/providers/claude-cli-version.d.ts +15 -0
  81. package/lib/providers/claude-cli-version.js +24 -0
  82. package/lib/providers/claude.d.ts +181 -0
  83. package/lib/providers/claude.js +680 -0
  84. package/lib/providers/codex-client-version.d.ts +8 -0
  85. package/lib/providers/codex-client-version.js +17 -0
  86. package/lib/providers/codex-search.d.ts +29 -0
  87. package/lib/providers/codex-search.js +198 -0
  88. package/lib/providers/codex.d.ts +229 -0
  89. package/lib/providers/codex.js +821 -0
  90. package/lib/providers/common.d.ts +389 -0
  91. package/lib/providers/common.js +656 -0
  92. package/lib/providers/copilot.d.ts +344 -0
  93. package/lib/providers/copilot.js +888 -0
  94. package/lib/providers/cursor-adapter.d.ts +15 -0
  95. package/lib/providers/cursor-adapter.js +39 -0
  96. package/lib/providers/cursor-auth.d.ts +43 -0
  97. package/lib/providers/cursor-auth.js +169 -0
  98. package/lib/providers/cursor-model-settings.d.ts +15 -0
  99. package/lib/providers/cursor-model-settings.js +51 -0
  100. package/lib/providers/cursor-usage.d.ts +9 -0
  101. package/lib/providers/cursor-usage.js +112 -0
  102. package/lib/providers/external-usage-controller.d.ts +19 -0
  103. package/lib/providers/external-usage-controller.js +32 -0
  104. package/lib/providers/external-usage.d.ts +8 -0
  105. package/lib/providers/external-usage.js +82 -0
  106. package/lib/providers/grok.d.ts +205 -0
  107. package/lib/providers/grok.js +713 -0
  108. package/lib/providers/image-pool.d.ts +30 -0
  109. package/lib/providers/image-pool.js +89 -0
  110. package/lib/providers/npm-cli-version.d.ts +56 -0
  111. package/lib/providers/npm-cli-version.js +98 -0
  112. package/lib/providers/pool-family.d.ts +56 -0
  113. package/lib/providers/pool-family.js +45 -0
  114. package/lib/providers/pool-health.d.ts +74 -0
  115. package/lib/providers/pool-health.js +150 -0
  116. package/lib/providers/pool-usage.d.ts +86 -0
  117. package/lib/providers/pool-usage.js +223 -0
  118. package/lib/providers/pool.d.ts +108 -0
  119. package/lib/providers/pool.js +368 -0
  120. package/lib/providers/rate-limit.d.ts +192 -0
  121. package/lib/providers/rate-limit.js +338 -0
  122. package/lib/tools/image-generate.d.ts +128 -0
  123. package/lib/tools/image-generate.js +438 -0
  124. package/lib/tools/registration.d.ts +15 -0
  125. package/lib/tools/registration.js +24 -0
  126. package/lib/tools/video-generate.d.ts +89 -0
  127. package/lib/tools/video-generate.js +257 -0
  128. package/lib/tools/x-search.d.ts +58 -0
  129. package/lib/tools/x-search.js +197 -0
  130. package/lib/translate/anthropic.d.ts +161 -0
  131. package/lib/translate/anthropic.js +515 -0
  132. package/lib/translate/antigravity-schema.d.ts +4 -0
  133. package/lib/translate/antigravity-schema.js +145 -0
  134. package/lib/translate/antigravity-thinking.d.ts +5 -0
  135. package/lib/translate/antigravity-thinking.js +44 -0
  136. package/lib/translate/antigravity.d.ts +114 -0
  137. package/lib/translate/antigravity.js +366 -0
  138. package/lib/translate/chat-completions.d.ts +120 -0
  139. package/lib/translate/chat-completions.js +382 -0
  140. package/lib/translate/resolved.d.ts +80 -0
  141. package/lib/translate/resolved.js +118 -0
  142. package/lib/translate/responses.d.ts +179 -0
  143. package/lib/translate/responses.js +408 -0
  144. package/lib/translate/sse.d.ts +21 -0
  145. package/lib/translate/sse.js +56 -0
  146. package/package.json +114 -0
  147. package/vendor/cursor/LICENSE +21 -0
  148. package/vendor/cursor/index.d.ts +18 -0
  149. package/vendor/cursor/index.js +3574 -0
  150. package/vendor/cursor/proto.js +254 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 V1ki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # DSH Subscription Hub
2
+
3
+ Bring your AI subscriptions into
4
+ [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH). One
5
+ plugin adds their models to the native picker and puts account controls and
6
+ reported quota in **Settings → Subscriptions**.
7
+
8
+ ## What it adds
9
+
10
+ - **Six subscription model sources:** Connect ChatGPT/Codex, Claude, Grok,
11
+ GitHub Copilot, Google Antigravity, or Cursor and choose their models in
12
+ DSH's picker. These routes use account sign-in, without provider API keys.
13
+ - **Account and model control:** Refresh catalogs and choose visible models.
14
+ For Codex, Claude, Grok, Copilot, and Antigravity, manage multiple accounts
15
+ and same-provider pools. Pools can use available quota to select an account
16
+ and fail over before a reply starts. See [account management](docs/account-management.md).
17
+ - **Quota while you work:** See reported usage windows and reset times on
18
+ provider cards and in an optional session-footer pill. The same view reads
19
+ OpenCode Go and Kimi Code usage through keys already configured in
20
+ **Settings → Models**. Their model routes remain built into DSH. GitHub
21
+ Copilot has no usage endpoint.
22
+ - **Provider tools:** Use Codex web search, Grok X search, ChatGPT or Grok
23
+ image generation and editing, and Grok video generation when the matching
24
+ provider is enabled.
25
+
26
+ ## Install
27
+
28
+ Current source is tested with DSH `0.1.7-rc.2`, `0.2.0-rc.1`, and
29
+ `0.2.0-rc.2`. The package's peers admit `>=0.1.7-rc.2 <0.3.0-0`: untested
30
+ versions inside that range install but are unsupported, and versions outside
31
+ it need an explicit `dsh plugin allow-version` exemption. See
32
+ [docs/compatibility.md](docs/compatibility.md)
33
+ for the support window and [docs/testing.md](docs/testing.md) for the test
34
+ layers. With `dsh` available, install the plugin into the web profile from
35
+ npm:
36
+
37
+ ```sh
38
+ dsh plugin --profile web add dsh-subscriptions
39
+ ```
40
+
41
+ Or from GitHub source:
42
+
43
+ ```sh
44
+ dsh plugin --profile web add github:goodboys-ai/dsh-subscription-hub
45
+ ```
46
+
47
+ Git installs run this package's `prepare` script to build the host and browser
48
+ bundles. If a Git install stops with `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`
49
+ (DSH `0.1.7-rc.2` does this by default), add this package to the web
50
+ profile's `pnpm-workspace.yaml` (`~/.dsh/profiles/web/` unless `DSH_HOME` is
51
+ set) and repeat the command:
52
+
53
+ ```yaml
54
+ allowBuilds:
55
+ 'dsh-subscriptions': true
56
+ ```
57
+
58
+ (If a previous Git install used the old scoped name, replace its
59
+ `allowBuilds` key with the one above.)
60
+
61
+ Restart `dsh web` after installation. Open **Settings → Subscriptions** to
62
+ connect providers, manage accounts and model lists, and inspect available
63
+ quota. The session footer quota pill can be shown or hidden there for the
64
+ current browser. OpenCode Go and Kimi Code cards show usage when their API
65
+ keys are configured in **Settings → Models**.
66
+
67
+ To update an installation, run the same `dsh plugin --profile web add` command again and restart `dsh web`.
68
+
69
+ Without a version suffix, the npm command installs the `latest` dist-tag and
70
+ the GitHub command installs the current default branch at install time. Pin
71
+ a release by appending `@<version>` to the npm spec or the tag to the GitHub
72
+ spec (`#vX.Y.Z`); see the
73
+ [release policy](docs/compatibility.md#plugin-versioning).
74
+
75
+ ## Verification and limits
76
+
77
+ CI builds, tests, and boots a packed tarball on both supported DSH versions;
78
+ it does not run the GitHub source install's `prepare` step. Offline
79
+ provider tests check recorded API responses, so a passing CI run cannot prove
80
+ that a provider still accepts live sign-in or model requests. See
81
+ [testing](docs/testing.md) for the checks and their limits.
82
+
83
+ - **ChatGPT/Codex, Claude, Grok, GitHub Copilot, and Google Antigravity:**
84
+ The port includes the tool-message and icon fixes tracked in
85
+ [upstream issue #117](https://github.com/V1ki/dsh-plugin-subscriptions/issues/117).
86
+ A manual check in an isolated `0.1.7-rc.2` web profile found the client
87
+ bundle served and the auth and usage-status RPCs answering. Live sign-in
88
+ and model requests on the supported DSH lines remain to be checked.
89
+ - **Cursor:** Browser sign-in and live model discovery were verified in an
90
+ isolated `0.1.7-rc.2` profile. A successful live model request remains to be
91
+ verified. Its transport comes from
92
+ [orrinzeng/dsh-cursor-subscription](https://github.com/orrinzeng/dsh-cursor-subscription)
93
+ and uses an undocumented Cursor protocol; see the
94
+ [source note](docs/cursor-origin.md).
95
+ - **OpenCode Go and Kimi Code:** Live usage responses were verified. Their API
96
+ keys stay on the DSH host; the browser receives configured status and quota
97
+ windows, not the keys.
98
+
99
+ Cursor uses one connected account and a live model catalog. Its model
100
+ visibility selection persists under the DSH home directory, and automatic
101
+ display follows newly discovered models. It ships no fallback model list and
102
+ does not import the local `cursor-agent` credential cache. Individual usage
103
+ comes from Cursor dashboard endpoints; the documented Admin API covers team
104
+ usage.
105
+
106
+ The plugin follows DSH network settings. Its former proxy configuration and
107
+ UI have been retired; an old plugin proxy config file is ignored.
108
+
109
+ ## Development
110
+
111
+ ```sh
112
+ pnpm install --frozen-lockfile
113
+ pnpm build
114
+ pnpm test
115
+ ```
116
+
117
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the PR workflow, test boundaries,
118
+ and isolated DSH checks.
119
+
120
+ The tests and checks (unit and integration tests, the host-export and host
121
+ contract checks, the boot smoke, and the host E2E) are documented in [docs/testing.md](docs/testing.md); the DSH
122
+ support window and release process in
123
+ [docs/compatibility.md](docs/compatibility.md).
124
+
125
+ The package is published to npm as `dsh-subscriptions`, starting with the first release under the new name (GitHub source installation remains supported). The `cordis.patch.yml` bundle entry and browser module ID use the same package name. Earlier releases were published as `@goodboys-ai/dsh-subscription-hub`; that name will be deprecated in favor of `dsh-subscriptions`. If the old scoped package is installed, remove it before adding the new one — installing both registers the same adapters twice and breaks plugin load.
126
+
127
+ ## Origins
128
+
129
+ This project started from [V1ki/dsh-plugin-subscriptions](https://github.com/V1ki/dsh-plugin-subscriptions) at `090d964` under its MIT license and now includes upstream `0.9.5` (`75a8346`). The upstream English and Chinese READMEs are kept in [`docs/upstream-README.md`](docs/upstream-README.md) and [`docs/upstream-README.zh.md`](docs/upstream-README.zh.md).
@@ -0,0 +1,12 @@
1
+ # Bundle self-activation patch: when this package is installed into a profile
2
+ # via `dsh plugin --profile web add dsh-subscriptions`
3
+ # (or a local checkout), this patch mounts the plugin's node half. The client
4
+ # half (Settings > Subscriptions page) is discovered automatically from the
5
+ # `dsh.client` declaration in package.json.
6
+ #
7
+ # Headless usage without installing into the profile (node half only; log in
8
+ # from a web profile first, the token file is shared):
9
+ # pnpm dsh --profile headless --patch <this-checkout>/overlay.yml "task"
10
+ - insert:
11
+ - id: llm-subscriptions
12
+ name: 'dsh-subscriptions'
@@ -0,0 +1,23 @@
1
+ import type { ClaudeSession } from './store.js';
2
+ /** Read the current Claude Code session from its source of truth: macOS Keychain, falling back to the credentials file. */
3
+ export declare function readClaudeCodeCredentials(): ClaudeSession | undefined;
4
+ /**
5
+ * Write a refreshed session back to Claude Code's own credential store, so
6
+ * the `claude` CLI and any other consumer of the same account see the token
7
+ * we just rotated. A stale-blob mismatch (something else rotated it first)
8
+ * is a no-op — the caller already has that other rotation via readClaudeCodeCredentials.
9
+ * @param next - the freshly refreshed session to persist.
10
+ * @param expectedPriorAccessToken - the access token this refresh started from.
11
+ * @returns whether the write-back succeeded.
12
+ */
13
+ export declare function writeBackClaudeCodeCredentials(next: ClaudeSession, expectedPriorAccessToken: string): boolean;
14
+ /**
15
+ * Refresh a Claude session, first checking whether Claude Code's own store
16
+ * already holds a fresher token (rotated by the `claude` CLI or another
17
+ * consumer) before hitting the OAuth endpoint ourselves — and writing our own
18
+ * refresh back to that store so every consumer of the account stays synced.
19
+ * @param session - the session TokenManager wants refreshed.
20
+ * @param doRefresh - the actual OAuth refresh-token grant (network call).
21
+ * @returns the freshest available session.
22
+ */
23
+ export declare function refreshClaudeSynced(session: ClaudeSession, doRefresh: (session: ClaudeSession) => Promise<ClaudeSession>): Promise<ClaudeSession>;
@@ -0,0 +1,172 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ const PRIMARY_SERVICE = 'Claude Code-credentials';
6
+ const DEFAULT_SCOPES = 'user:profile user:inference user:sessions:claude_code user:mcp_servers';
7
+ function toSession(data) {
8
+ // Empty-string tokens (seen from a corrupted Keychain item left by a Claude
9
+ // Code logout) pass the typeof gate but are useless and would poison the
10
+ // auth store — a single such entry fails every provider's status read.
11
+ if (typeof data.accessToken !== 'string' || data.accessToken.length === 0
12
+ || typeof data.refreshToken !== 'string' || data.refreshToken.length === 0
13
+ || typeof data.expiresAt !== 'number' || !Number.isFinite(data.expiresAt)) {
14
+ return undefined;
15
+ }
16
+ const scopes = Array.isArray(data.scopes) ? data.scopes.join(' ') : typeof data.scopes === 'string' ? data.scopes : DEFAULT_SCOPES;
17
+ return {
18
+ accessToken: data.accessToken,
19
+ refreshToken: data.refreshToken,
20
+ expiresAt: Math.trunc(data.expiresAt),
21
+ scopes,
22
+ ...typeof data.emailAddress === 'string' ? { emailAddress: data.emailAddress } : {},
23
+ ...typeof data.subscriptionType === 'string' ? { subscriptionType: data.subscriptionType } : {},
24
+ };
25
+ }
26
+ function parseBlob(raw) {
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(raw);
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ return toSession(parsed.claudeAiOauth ?? parsed);
35
+ }
36
+ function credentialsFilePath() {
37
+ return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), '.credentials.json');
38
+ }
39
+ function readKeychainRaw() {
40
+ try {
41
+ return execFileSync('/usr/bin/security', ['find-generic-password', '-s', PRIMARY_SERVICE, '-w'], {
42
+ timeout: 3000,
43
+ encoding: 'utf8',
44
+ stdio: ['pipe', 'pipe', 'pipe'],
45
+ }).trim();
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ function readFileRaw() {
52
+ try {
53
+ return readFileSync(credentialsFilePath(), 'utf8');
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ /** Read the current Claude Code session from its source of truth: macOS Keychain, falling back to the credentials file. */
60
+ export function readClaudeCodeCredentials() {
61
+ if (process.platform === 'darwin') {
62
+ const raw = readKeychainRaw();
63
+ const session = raw !== undefined ? parseBlob(raw) : undefined;
64
+ if (session)
65
+ return session;
66
+ }
67
+ const raw = readFileRaw();
68
+ return raw !== undefined ? parseBlob(raw) : undefined;
69
+ }
70
+ function blobMatches(raw, expectedAccessToken) {
71
+ return parseBlob(raw)?.accessToken === expectedAccessToken;
72
+ }
73
+ function getKeychainAccountName() {
74
+ try {
75
+ const output = execFileSync('/usr/bin/security', ['find-generic-password', '-s', PRIMARY_SERVICE], {
76
+ timeout: 2000,
77
+ encoding: 'utf8',
78
+ stdio: ['pipe', 'pipe', 'pipe'],
79
+ });
80
+ return /"acct"<blob>="([^"]*)"/.exec(output)?.[1];
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ }
86
+ /** Merge fresh tokens into an existing raw blob, preserving unrelated fields. */
87
+ function mergeIntoBlob(existingRaw, next) {
88
+ let parsed;
89
+ try {
90
+ parsed = JSON.parse(existingRaw);
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ const target = (parsed.claudeAiOauth ?? parsed);
96
+ target.accessToken = next.accessToken;
97
+ target.refreshToken = next.refreshToken;
98
+ target.expiresAt = next.expiresAt;
99
+ return JSON.stringify(parsed);
100
+ }
101
+ /**
102
+ * Write a refreshed session back to Claude Code's own credential store, so
103
+ * the `claude` CLI and any other consumer of the same account see the token
104
+ * we just rotated. A stale-blob mismatch (something else rotated it first)
105
+ * is a no-op — the caller already has that other rotation via readClaudeCodeCredentials.
106
+ * @param next - the freshly refreshed session to persist.
107
+ * @param expectedPriorAccessToken - the access token this refresh started from.
108
+ * @returns whether the write-back succeeded.
109
+ */
110
+ export function writeBackClaudeCodeCredentials(next, expectedPriorAccessToken) {
111
+ if (process.platform === 'darwin') {
112
+ const raw = readKeychainRaw();
113
+ if (raw === undefined || !blobMatches(raw, expectedPriorAccessToken))
114
+ return false;
115
+ const updated = mergeIntoBlob(raw, next);
116
+ if (updated === undefined)
117
+ return false;
118
+ const account = getKeychainAccountName() ?? PRIMARY_SERVICE;
119
+ try {
120
+ execFileSync('/usr/bin/security', ['add-generic-password', '-s', PRIMARY_SERVICE, '-a', account, '-w', updated, '-U'], {
121
+ timeout: 2000,
122
+ stdio: 'ignore',
123
+ });
124
+ return true;
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ }
130
+ const path = credentialsFilePath();
131
+ let raw;
132
+ try {
133
+ raw = readFileSync(path, 'utf8');
134
+ }
135
+ catch {
136
+ return false;
137
+ }
138
+ if (!blobMatches(raw, expectedPriorAccessToken))
139
+ return false;
140
+ const updated = mergeIntoBlob(raw, next);
141
+ if (updated === undefined)
142
+ return false;
143
+ try {
144
+ const dir = dirname(path);
145
+ if (!existsSync(dir))
146
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
147
+ writeFileSync(path, updated, { encoding: 'utf8', mode: 0o600 });
148
+ chmodSync(path, 0o600);
149
+ return true;
150
+ }
151
+ catch {
152
+ return false;
153
+ }
154
+ }
155
+ /**
156
+ * Refresh a Claude session, first checking whether Claude Code's own store
157
+ * already holds a fresher token (rotated by the `claude` CLI or another
158
+ * consumer) before hitting the OAuth endpoint ourselves — and writing our own
159
+ * refresh back to that store so every consumer of the account stays synced.
160
+ * @param session - the session TokenManager wants refreshed.
161
+ * @param doRefresh - the actual OAuth refresh-token grant (network call).
162
+ * @returns the freshest available session.
163
+ */
164
+ export async function refreshClaudeSynced(session, doRefresh) {
165
+ const fromSource = readClaudeCodeCredentials();
166
+ const base = fromSource !== undefined && fromSource.accessToken !== session.accessToken ? fromSource : session;
167
+ if (base.expiresAt > Date.now() + 60_000)
168
+ return base;
169
+ const next = await doRefresh(base);
170
+ writeBackClaudeCodeCredentials(next, base.accessToken);
171
+ return next;
172
+ }
@@ -0,0 +1,55 @@
1
+ /** Static per-provider device-flow facts. */
2
+ export interface DeviceFlowSpec {
3
+ /** OAuth App / GitHub App client id the device code is requested for. */
4
+ clientId: string;
5
+ /** Scope string requested at device-code time. */
6
+ scope: string;
7
+ /** Device-code endpoint (e.g. `https://github.com/login/device/code`). */
8
+ deviceCodeUrl: string;
9
+ /** Token polling endpoint (e.g. `https://github.com/login/oauth/access_token`). */
10
+ tokenUrl: string;
11
+ /** Fetch implementation (injectable for tests). */
12
+ fetchFn?: typeof fetch;
13
+ }
14
+ /** One in-flight device-flow login attempt. */
15
+ export interface DeviceAttempt {
16
+ /** URL the user opens to authorize (e.g. `https://github.com/login/device`). */
17
+ readonly verificationUrl: string;
18
+ /** Short code the user types at the verification URL. */
19
+ readonly userCode: string;
20
+ /**
21
+ * Poll until GitHub releases the access token.
22
+ * @returns the GitHub OAuth access token; rejects on timeout, denial, or cancel.
23
+ */
24
+ waitToken(): Promise<string>;
25
+ /** Abort the attempt; `waitToken` rejects with a cancellation error. */
26
+ cancel(): void;
27
+ }
28
+ /**
29
+ * Own the set of in-flight device-flow attempts, keyed by provider. One
30
+ * attempt per provider at a time; an attempt removes itself when it settles.
31
+ */
32
+ export declare class DeviceFlowManager {
33
+ private attempts;
34
+ /**
35
+ * Whether a device-flow attempt is running for one provider.
36
+ * @param provider - the provider route.
37
+ * @returns true while an attempt is polling.
38
+ */
39
+ isBusy(provider: string): boolean;
40
+ /**
41
+ * The pending attempt for one provider, when any.
42
+ * @param provider - the provider route.
43
+ * @returns the in-flight attempt, or `undefined`.
44
+ */
45
+ pending(provider: string): DeviceAttempt | undefined;
46
+ /**
47
+ * Start a device-flow attempt: request a device code, then poll the token
48
+ * endpoint in the background of `waitToken`.
49
+ * @param provider - the provider route (one attempt at a time).
50
+ * @param spec - static flow facts for this provider.
51
+ * @returns the live attempt; its `waitToken()` settles the login.
52
+ * @throws when an attempt is already running or the device-code request fails.
53
+ */
54
+ start(provider: string, spec: DeviceFlowSpec): Promise<DeviceAttempt>;
55
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * GitHub OAuth device-authorization flow (RFC 8628) for providers that cannot
3
+ * use the loopback redirect engine: no redirect URI, no PKCE, no client
4
+ * secret. The user opens a verification URL and types a short code while the
5
+ * plugin polls the token endpoint until GitHub releases the access token.
6
+ * The management model (one attempt per provider, `isBusy`/`pending`/`cancel`)
7
+ * mirrors {@link OAuthFlowManager} so the auth controller can treat both
8
+ * engines uniformly.
9
+ */
10
+ import { hostFetch } from '../http.js';
11
+ /** Default poll interval when the device-code response omits one. */
12
+ const DEFAULT_INTERVAL_SEC = 5;
13
+ /** Default device-code lifetime when the response omits one (GitHub: 15 minutes). */
14
+ const DEFAULT_EXPIRES_IN_SEC = 900;
15
+ /** Sleep for `ms`, rejecting early when the signal aborts. */
16
+ function sleep(ms, signal) {
17
+ return new Promise((resolve, reject) => {
18
+ if (signal.aborted) {
19
+ reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
20
+ return;
21
+ }
22
+ const timer = setTimeout(() => {
23
+ signal.removeEventListener('abort', onAbort);
24
+ resolve();
25
+ }, ms);
26
+ timer.unref();
27
+ const onAbort = () => {
28
+ clearTimeout(timer);
29
+ reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
30
+ };
31
+ signal.addEventListener('abort', onAbort, { once: true });
32
+ });
33
+ }
34
+ /**
35
+ * Own the set of in-flight device-flow attempts, keyed by provider. One
36
+ * attempt per provider at a time; an attempt removes itself when it settles.
37
+ */
38
+ export class DeviceFlowManager {
39
+ attempts = new Map();
40
+ /**
41
+ * Whether a device-flow attempt is running for one provider.
42
+ * @param provider - the provider route.
43
+ * @returns true while an attempt is polling.
44
+ */
45
+ isBusy(provider) {
46
+ return this.attempts.has(provider);
47
+ }
48
+ /**
49
+ * The pending attempt for one provider, when any.
50
+ * @param provider - the provider route.
51
+ * @returns the in-flight attempt, or `undefined`.
52
+ */
53
+ pending(provider) {
54
+ return this.attempts.get(provider);
55
+ }
56
+ /**
57
+ * Start a device-flow attempt: request a device code, then poll the token
58
+ * endpoint in the background of `waitToken`.
59
+ * @param provider - the provider route (one attempt at a time).
60
+ * @param spec - static flow facts for this provider.
61
+ * @returns the live attempt; its `waitToken()` settles the login.
62
+ * @throws when an attempt is already running or the device-code request fails.
63
+ */
64
+ async start(provider, spec) {
65
+ if (this.attempts.has(provider)) {
66
+ throw new Error(`a ${provider} login attempt is already in progress`);
67
+ }
68
+ const fetchFn = spec.fetchFn ?? hostFetch;
69
+ const response = await fetchFn(spec.deviceCodeUrl, {
70
+ method: 'POST',
71
+ headers: {
72
+ 'accept': 'application/json',
73
+ 'content-type': 'application/x-www-form-urlencoded',
74
+ },
75
+ body: new URLSearchParams({ client_id: spec.clientId, scope: spec.scope }).toString(),
76
+ });
77
+ if (!response.ok) {
78
+ throw new Error(`${provider} device-code request failed (HTTP ${String(response.status)})`);
79
+ }
80
+ const wire = await response.json();
81
+ if (typeof wire.device_code !== 'string' || wire.device_code.length === 0
82
+ || typeof wire.user_code !== 'string' || wire.user_code.length === 0
83
+ || typeof wire.verification_uri !== 'string' || wire.verification_uri.length === 0) {
84
+ throw new Error(`${provider} device-code response is missing device_code/user_code/verification_uri`);
85
+ }
86
+ const intervalSec = typeof wire.interval === 'number' && wire.interval > 0
87
+ ? wire.interval
88
+ : DEFAULT_INTERVAL_SEC;
89
+ const expiresInSec = typeof wire.expires_in === 'number' && wire.expires_in > 0
90
+ ? wire.expires_in
91
+ : DEFAULT_EXPIRES_IN_SEC;
92
+ const controller = new AbortController();
93
+ let resolveToken;
94
+ let rejectToken;
95
+ const tokenPromise = new Promise((resolve, reject) => {
96
+ resolveToken = resolve;
97
+ rejectToken = reject;
98
+ });
99
+ // The promise settles exactly once, from the poll loop below; an unhandled
100
+ // rejection must not surface if nobody awaited waitToken after a cancel.
101
+ tokenPromise.catch(() => undefined);
102
+ const settle = (error, token) => {
103
+ // Identity check: a late settle from a stale attempt (its poll loop or a
104
+ // cancel arriving after it already settled) must not kill a NEW attempt
105
+ // the user started for the same provider.
106
+ if (this.attempts.get(provider) !== attempt)
107
+ return;
108
+ this.attempts.delete(provider);
109
+ if (error !== undefined)
110
+ rejectToken(error);
111
+ else if (token !== undefined)
112
+ resolveToken(token);
113
+ };
114
+ const poll = async () => {
115
+ let intervalMs = intervalSec * 1000;
116
+ const deadline = Date.now() + expiresInSec * 1000;
117
+ while (true) {
118
+ await sleep(intervalMs, controller.signal);
119
+ if (Date.now() >= deadline) {
120
+ settle(new Error(`login timed out after ${String(Math.round(expiresInSec))}s`));
121
+ return;
122
+ }
123
+ const pollResponse = await fetchFn(spec.tokenUrl, {
124
+ method: 'POST',
125
+ headers: {
126
+ 'accept': 'application/json',
127
+ 'content-type': 'application/x-www-form-urlencoded',
128
+ },
129
+ body: new URLSearchParams({
130
+ client_id: spec.clientId,
131
+ device_code: wire.device_code,
132
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
133
+ }).toString(),
134
+ signal: controller.signal,
135
+ });
136
+ const result = await pollResponse.json();
137
+ if (typeof result.access_token === 'string' && result.access_token.length > 0) {
138
+ settle(undefined, result.access_token);
139
+ return;
140
+ }
141
+ switch (result.error) {
142
+ case 'authorization_pending':
143
+ break;
144
+ case 'slow_down':
145
+ // RFC 8628 §3.5: add five seconds to the poll interval.
146
+ intervalMs += 5000;
147
+ break;
148
+ case 'access_denied':
149
+ settle(new Error('login declined on the GitHub authorization page'));
150
+ return;
151
+ case 'expired_token':
152
+ settle(new Error('the device code expired before authorization completed'));
153
+ return;
154
+ default:
155
+ settle(new Error(`${provider} device-flow polling failed: ${result.error_description ?? result.error ?? `HTTP ${String(pollResponse.status)}`}`));
156
+ return;
157
+ }
158
+ }
159
+ };
160
+ const attempt = {
161
+ verificationUrl: wire.verification_uri,
162
+ userCode: wire.user_code,
163
+ waitToken: () => tokenPromise,
164
+ cancel: () => {
165
+ controller.abort(new Error('login cancelled'));
166
+ settle(new Error('login cancelled'));
167
+ },
168
+ };
169
+ this.attempts.set(provider, attempt);
170
+ void poll().catch((error) => {
171
+ // Aborts land here from sleep/fetch; everything else is a transport or
172
+ // parse failure worth surfacing as the login failure.
173
+ settle(error instanceof Error ? error : new Error(String(error)));
174
+ });
175
+ return attempt;
176
+ }
177
+ }
@@ -0,0 +1,10 @@
1
+ /** Minimal JWT payload decoding for claims extraction (no signature verification). */
2
+ /**
3
+ * Decode a JWT payload without verifying the signature. Used only to read
4
+ * account claims from `id_token`s issued over the provider's own TLS channel
5
+ * during a code exchange we initiated — never to authorize anything.
6
+ * @param token - the compact JWT string.
7
+ * @returns the parsed payload object, or `undefined` when the token is not a
8
+ * well-formed JWT with a JSON object payload.
9
+ */
10
+ export declare function decodeJwtPayload(token: string): Record<string, unknown> | undefined;
@@ -0,0 +1,25 @@
1
+ /** Minimal JWT payload decoding for claims extraction (no signature verification). */
2
+ /**
3
+ * Decode a JWT payload without verifying the signature. Used only to read
4
+ * account claims from `id_token`s issued over the provider's own TLS channel
5
+ * during a code exchange we initiated — never to authorize anything.
6
+ * @param token - the compact JWT string.
7
+ * @returns the parsed payload object, or `undefined` when the token is not a
8
+ * well-formed JWT with a JSON object payload.
9
+ */
10
+ export function decodeJwtPayload(token) {
11
+ const parts = token.split('.');
12
+ if (parts.length < 2)
13
+ return undefined;
14
+ let parsed;
15
+ try {
16
+ parsed = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
17
+ }
18
+ catch {
19
+ // Malformed base64url or non-JSON payload: the token is unusable for claims.
20
+ return undefined;
21
+ }
22
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
23
+ return undefined;
24
+ return parsed;
25
+ }