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.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/cordis.patch.yml +12 -0
- package/lib/auth/claude-code-creds.d.ts +23 -0
- package/lib/auth/claude-code-creds.js +172 -0
- package/lib/auth/device-flow.d.ts +55 -0
- package/lib/auth/device-flow.js +177 -0
- package/lib/auth/jwt.d.ts +10 -0
- package/lib/auth/jwt.js +25 -0
- package/lib/auth/oauth-flow.d.ts +91 -0
- package/lib/auth/oauth-flow.js +227 -0
- package/lib/auth/pkce.d.ts +31 -0
- package/lib/auth/pkce.js +35 -0
- package/lib/auth/rpc.d.ts +209 -0
- package/lib/auth/rpc.js +386 -0
- package/lib/auth/store.d.ts +196 -0
- package/lib/auth/store.js +468 -0
- package/lib/client/CursorCard.d.ts +9 -0
- package/lib/client/CursorCard.js +113 -0
- package/lib/client/ExternalUsageCards.d.ts +9 -0
- package/lib/client/ExternalUsageCards.js +72 -0
- package/lib/client/ImageGallery.d.ts +54 -0
- package/lib/client/ImageGallery.js +112 -0
- package/lib/client/ImageGenerateToolview.d.ts +48 -0
- package/lib/client/ImageGenerateToolview.js +133 -0
- package/lib/client/ProviderAccountManager.d.ts +13 -0
- package/lib/client/ProviderAccountManager.js +147 -0
- package/lib/client/ProviderModelEditor.d.ts +39 -0
- package/lib/client/ProviderModelEditor.js +107 -0
- package/lib/client/SpeedSelect.d.ts +71 -0
- package/lib/client/SpeedSelect.js +177 -0
- package/lib/client/SubscriptionUsageBadge.d.ts +116 -0
- package/lib/client/SubscriptionUsageBadge.js +543 -0
- package/lib/client/SubscriptionsSection.d.ts +140 -0
- package/lib/client/SubscriptionsSection.js +544 -0
- package/lib/client/UsageBadgeDisplaySetting.d.ts +7 -0
- package/lib/client/UsageBadgeDisplaySetting.js +36 -0
- package/lib/client/VideoGenerateToolview.d.ts +42 -0
- package/lib/client/VideoGenerateToolview.js +175 -0
- package/lib/client/account-preferences.d.ts +24 -0
- package/lib/client/account-preferences.js +21 -0
- package/lib/client/fast-command.d.ts +2 -0
- package/lib/client/fast-command.js +4 -0
- package/lib/client/host-contract.d.ts +90 -0
- package/lib/client/host-contract.js +101 -0
- package/lib/client/host-icons.d.ts +24 -0
- package/lib/client/host-icons.js +21 -0
- package/lib/client/index.d.ts +27 -0
- package/lib/client/index.js +136 -0
- package/lib/client/locales.d.ts +297 -0
- package/lib/client/locales.js +295 -0
- package/lib/client/subscription-card-styles.d.ts +3 -0
- package/lib/client/subscription-card-styles.js +53 -0
- package/lib/client/subscriptions-rpc.d.ts +14 -0
- package/lib/client/subscriptions-rpc.js +34 -0
- package/lib/client/usage-badge-preferences.d.ts +11 -0
- package/lib/client/usage-badge-preferences.js +41 -0
- package/lib/client.js +4472 -0
- package/lib/client.js.map +1 -0
- package/lib/compat.d.ts +36 -0
- package/lib/compat.js +20 -0
- package/lib/http.d.ts +6 -0
- package/lib/http.js +15 -0
- package/lib/index.d.ts +80 -0
- package/lib/index.js +14018 -0
- package/lib/model-defaults.d.ts +23 -0
- package/lib/model-defaults.js +237 -0
- package/lib/provider-settings.d.ts +39 -0
- package/lib/provider-settings.js +151 -0
- package/lib/providers/account-preferences.d.ts +41 -0
- package/lib/providers/account-preferences.js +153 -0
- package/lib/providers/accounts.d.ts +112 -0
- package/lib/providers/accounts.js +142 -0
- package/lib/providers/antigravity-oauth-client.d.ts +7 -0
- package/lib/providers/antigravity-oauth-client.js +7 -0
- package/lib/providers/antigravity.d.ts +110 -0
- package/lib/providers/antigravity.js +516 -0
- package/lib/providers/catalog-store.d.ts +48 -0
- package/lib/providers/catalog-store.js +243 -0
- package/lib/providers/claude-cli-version.d.ts +15 -0
- package/lib/providers/claude-cli-version.js +24 -0
- package/lib/providers/claude.d.ts +181 -0
- package/lib/providers/claude.js +680 -0
- package/lib/providers/codex-client-version.d.ts +8 -0
- package/lib/providers/codex-client-version.js +17 -0
- package/lib/providers/codex-search.d.ts +29 -0
- package/lib/providers/codex-search.js +198 -0
- package/lib/providers/codex.d.ts +229 -0
- package/lib/providers/codex.js +821 -0
- package/lib/providers/common.d.ts +389 -0
- package/lib/providers/common.js +656 -0
- package/lib/providers/copilot.d.ts +344 -0
- package/lib/providers/copilot.js +888 -0
- package/lib/providers/cursor-adapter.d.ts +15 -0
- package/lib/providers/cursor-adapter.js +39 -0
- package/lib/providers/cursor-auth.d.ts +43 -0
- package/lib/providers/cursor-auth.js +169 -0
- package/lib/providers/cursor-model-settings.d.ts +15 -0
- package/lib/providers/cursor-model-settings.js +51 -0
- package/lib/providers/cursor-usage.d.ts +9 -0
- package/lib/providers/cursor-usage.js +112 -0
- package/lib/providers/external-usage-controller.d.ts +19 -0
- package/lib/providers/external-usage-controller.js +32 -0
- package/lib/providers/external-usage.d.ts +8 -0
- package/lib/providers/external-usage.js +82 -0
- package/lib/providers/grok.d.ts +205 -0
- package/lib/providers/grok.js +713 -0
- package/lib/providers/image-pool.d.ts +30 -0
- package/lib/providers/image-pool.js +89 -0
- package/lib/providers/npm-cli-version.d.ts +56 -0
- package/lib/providers/npm-cli-version.js +98 -0
- package/lib/providers/pool-family.d.ts +56 -0
- package/lib/providers/pool-family.js +45 -0
- package/lib/providers/pool-health.d.ts +74 -0
- package/lib/providers/pool-health.js +150 -0
- package/lib/providers/pool-usage.d.ts +86 -0
- package/lib/providers/pool-usage.js +223 -0
- package/lib/providers/pool.d.ts +108 -0
- package/lib/providers/pool.js +368 -0
- package/lib/providers/rate-limit.d.ts +192 -0
- package/lib/providers/rate-limit.js +338 -0
- package/lib/tools/image-generate.d.ts +128 -0
- package/lib/tools/image-generate.js +438 -0
- package/lib/tools/registration.d.ts +15 -0
- package/lib/tools/registration.js +24 -0
- package/lib/tools/video-generate.d.ts +89 -0
- package/lib/tools/video-generate.js +257 -0
- package/lib/tools/x-search.d.ts +58 -0
- package/lib/tools/x-search.js +197 -0
- package/lib/translate/anthropic.d.ts +161 -0
- package/lib/translate/anthropic.js +515 -0
- package/lib/translate/antigravity-schema.d.ts +4 -0
- package/lib/translate/antigravity-schema.js +145 -0
- package/lib/translate/antigravity-thinking.d.ts +5 -0
- package/lib/translate/antigravity-thinking.js +44 -0
- package/lib/translate/antigravity.d.ts +114 -0
- package/lib/translate/antigravity.js +366 -0
- package/lib/translate/chat-completions.d.ts +120 -0
- package/lib/translate/chat-completions.js +382 -0
- package/lib/translate/resolved.d.ts +80 -0
- package/lib/translate/resolved.js +118 -0
- package/lib/translate/responses.d.ts +179 -0
- package/lib/translate/responses.js +408 -0
- package/lib/translate/sse.d.ts +21 -0
- package/lib/translate/sse.js +56 -0
- package/package.json +114 -0
- package/vendor/cursor/LICENSE +21 -0
- package/vendor/cursor/index.d.ts +18 -0
- package/vendor/cursor/index.js +3574 -0
- 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).
|
package/cordis.patch.yml
ADDED
|
@@ -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;
|
package/lib/auth/jwt.js
ADDED
|
@@ -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
|
+
}
|