dsh-agy 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-agy contributors
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,174 @@
1
+ # dsh-agy
2
+
3
+ Google Antigravity (agy) access for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness):
4
+ OAuth authentication, a multi-account pool with automatic 429 rotation, device
5
+ fingerprinting, and both CLI and web management.
6
+
7
+ > 中文文档:[docs/README_zh.md](docs/README_zh.md)
8
+
9
+ ## Features
10
+
11
+ - **OAuth login**: one-click sign-in via browser OAuth callback, with headless
12
+ paste-URL mode and a remote paste-credential blob channel.
13
+ - **Two management surfaces**: web and CLI, either one works, core features are
14
+ the same.
15
+ - **Multi-account pool**: encrypted account store, automatic rotation on rate
16
+ limits, per-account cooldown with tiered backoff, per-account device
17
+ fingerprints.
18
+ - **Quota dashboard**: only active when DSH Web is running; append `/agy` to
19
+ your dsh web address: login, account management, per-model quota bars, model
20
+ testing, credential export/import, fingerprint management.
21
+ - **CLI**: `dsh-agy login|status|import|verify|logout` works standalone, with or
22
+ without a harness.
23
+
24
+ ## Quickstart
25
+
26
+ ### Path A: DSH Web GUI Users (Recommended — 100% Web UI, zero CLI commands)
27
+
28
+ For users using DeepSeek Harness browser workspace / Web GUI:
29
+
30
+ ```sh
31
+ # 1. Install plugin into DSH web profile (via dsh CLI, or pnpx/npx if dsh is not in PATH)
32
+ dsh plugin --profile web add dsh-agy
33
+ # or: npx @deepseek-ai/dsh plugin --profile web add dsh-agy
34
+
35
+ # 2. Launch DSH Web
36
+ dsh web
37
+
38
+ # 3. Open dashboard at http://127.0.0.1:3080/agy
39
+ # Click "Login with Google", complete OAuth authorization, and start using the agy provider
40
+ ```
41
+
42
+ ### Path B: Headless / Terminal Only (Standalone CLI)
43
+
44
+ For Linux VPS, SSH remote servers, or headless CI environments:
45
+
46
+ ```sh
47
+ # Run directly without global install (npx / pnpx)
48
+ npx dsh-agy login
49
+ npx dsh-agy status
50
+
51
+ # Or install globally
52
+ npm install -g dsh-agy
53
+ dsh-agy login # interactive OAuth (browser, --headless paste, or --blob)
54
+ dsh-agy status # list accounts + quota summary
55
+ dsh-agy verify # refresh + health check
56
+ dsh-agy import <file> # import agy auth.json or credential blob (--blob)
57
+ dsh-agy logout # remove account
58
+ ```
59
+
60
+ ### Path C: Local Development & Link
61
+
62
+ ```sh
63
+ git clone https://github.com/chaos-03x/dsh-agy.git
64
+ cd dsh-agy && pnpm install && pnpm run build
65
+ dsh plugin --profile web link .
66
+ ```
67
+
68
+ Requires Node >= 20.
69
+
70
+ ## Uninstall
71
+
72
+ ```sh
73
+ # 1. Remove the DSH plugin from a profile
74
+ dsh plugin --profile web remove dsh-agy
75
+
76
+ # 2. Uninstall the CLI
77
+ npm uninstall -g dsh-agy
78
+
79
+ # 3. Optional: delete local account data (accounts + master key + fingerprint override)
80
+ dsh-agy logout # remove accounts first (or skip)
81
+ rm -f ~/.dsh/agy-accounts.json
82
+ # remove only the AGY_MASTER_KEY line from ~/.dsh/.credentials.yaml — keep other keys!
83
+ rm -f ~/.dsh/agy-fingerprint-data.json # only if you created an override
84
+
85
+ # 4. Optional: revoke the Google-side authorization
86
+ # Google account security → Third-party access → revoke "Antigravity"
87
+ ```
88
+
89
+ Deleting local files does **not** revoke Google-side tokens; the refresh token stays
90
+ valid until it expires or you revoke it in your Google account security settings.
91
+
92
+ ## Other things you may care about
93
+
94
+ ### Rotation mechanics
95
+
96
+ 429 (Too Many Requests) responses:
97
+
98
+ | Category | Behavior |
99
+ |---|---|
100
+ | `soft_rate_limit` (Retry-After < 3s) | immediate retry on the same account, no cooldown |
101
+ | `rate_limited` | 5-minute cooldown + switch to the next account (same account when single) |
102
+ | `quota_exhausted` ("quota reached", "individual quota", RESOURCE_EXHAUSTED…) | 24-hour cooldown — no further calls to that account for the day |
103
+ | `unknown` | exponential backoff |
104
+
105
+ 401/403 → account revoked (marked for re-authentication). Success resets the failure
106
+ counter.
107
+
108
+ ### About cache hits: why not 99% like DeepSeek V4?
109
+
110
+ Bottom line: the cache hit strategy is decided by the model provider's cache
111
+ mechanics (for us, Antigravity's); agy's mechanics differ from DeepSeek's in
112
+ two ways, so its hit rate is naturally a notch below DeepSeek's.
113
+
114
+ **First, the entry threshold.** DeepSeek's caching is on by default with no
115
+ threshold — its very first request already hits a previously cached system
116
+ prompt. agy's Gemini-tiered models only start caching once the request prefix
117
+ reaches roughly 16k tokens, while DSH's default bare system prompt is only
118
+ about 13k — below the line. So every new conversation's first 1-2 requests
119
+ are 0%, until the accumulated messages pass 16k.
120
+
121
+ **Second, how fast the cache updates.** DeepSeek refreshes its cache at the
122
+ end of every request — only the newest message misses each round, giving
123
+ near-100%. agy's cache updates lag: this round's additions are not hit in
124
+ the next round — they enter the cache roughly two rounds later, and requests
125
+ for the same content in between all count as misses. Every round wastes
126
+ about 1.5-2× its additions; the long-conversation hit rate keeps rising as
127
+ the context grows, bounded by the model's context window.
128
+
129
+ **Practical tips**
130
+
131
+ - Don't expect 99% from agy: the gap comes from upstream mechanics, with no
132
+ room to optimize.
133
+ - If you have a weird number obsession, stuff some custom content into the
134
+ System Prompt (MCP / tool definitions / roleplay ...).
135
+
136
+ ### Storage & secrets
137
+
138
+ - Accounts: `~/.dsh/agy-accounts.json` — AES-256-GCM encrypted; the master key lives
139
+ in `~/.dsh/.credentials.yaml` (`AGY_MASTER_KEY`, 0600). `$DSH_HOME` relocates both.
140
+ - Fingerprint pools (version strings, SDK clients) are user-overridable via
141
+ `~/.dsh/agy-fingerprint-data.json` — no code release needed to keep them current.
142
+
143
+ ## ⚠️ Disclaimer
144
+
145
+ This plugin authenticates with Google's consumer OAuth client that ships with the
146
+ Antigravity desktop product and uses the Antigravity Cloud Code API outside of that
147
+ product. This may violate Antigravity's terms of service. **Use at your own risk** —
148
+ accounts can be rate-limited, throttled, or banned. Multi-account rotation, device
149
+ fingerprinting, and the signature-bypass sentinel are enabled by default and are
150
+ designed to work around upstream limits; you are responsible for how you use them and
151
+ for any account consequences.
152
+
153
+ ## Credits
154
+
155
+ This project references logic and data from the following MIT-licensed sources:
156
+
157
+ | Source | Content |
158
+ |---|---|
159
+ | [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) (archived) | OAuth flow shape, account-store schema & versioned migration, 429/backoff concepts, fingerprint design |
160
+ | [antigravity-claude-proxy PR #170](https://github.com/badrisnarayanan/antigravity-claude-proxy/pull/170) | Device fingerprint generation (via opencode-antigravity-auth) |
161
+ | [OmniRoute](https://github.com/diegosouzapw/OmniRoute) | Wire format (envelope, headers, SSE), endpoint order, `agy` token-file parsing, paste-credential blob codec, thoughtSignature replay, 429 category engine |
162
+ | [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | Plugin shell, `LlmAdapter` seam, DSH conventions |
163
+
164
+ ## Development
165
+
166
+ ```sh
167
+ pnpm install
168
+ pnpm test # vitest, fixture-driven, no network
169
+ pnpm run record:fixtures # re-record real-API fixtures (needs a real account)
170
+ pnpm run e2e # real-account end-to-end (needs AGY_REFRESH_TOKEN)
171
+ pnpm run debug:request # endpoint/header bisection probe
172
+ pnpm run verify:tools # live two-turn tool-signature check
173
+ npm pack --dry-run # verify the publishable artifact
174
+ ```
package/bin/dsh-agy.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // dsh-agy CLI launcher (built output).
3
+ import { createProgram } from '../lib/cli/index.mjs'
4
+
5
+ const program = createProgram()
6
+ await program.parseAsync(process.argv)
@@ -0,0 +1,13 @@
1
+ # dsh-agy bundle patch: mounts the dsh-agy plugins into a dsh profile.
2
+
3
+ # Install with: dsh plugin --profile <name> add dsh-agy
4
+ # The loader inserts these rows; the main plugin registers the agy LLM adapter
5
+ # routes, the web entry registers the /agy UI once ctx.webServer activates
6
+ # (it stays pending in profiles without a web server).
7
+
8
+ - insert:
9
+ - id: dsh-agy
10
+ name: 'dsh-agy'
11
+
12
+ - id: dsh-agy-web
13
+ name: 'dsh-agy/web'
@@ -0,0 +1,63 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
+ //#region src/oauth/blob.ts
3
+ var blob_exports = /* @__PURE__ */ __exportAll({
4
+ CREDENTIAL_BLOB_PREFIX: () => CREDENTIAL_BLOB_PREFIX,
5
+ CREDENTIAL_BLOB_PROVIDER: () => "agy",
6
+ decodeCredentialBlob: () => decodeCredentialBlob,
7
+ encodeCredentialBlob: () => encodeCredentialBlob
8
+ });
9
+ /**
10
+ * Paste-safe credential blob codec for remote login.
11
+ *
12
+ * Google's `firstparty/nativeapp` consent for this embedded client only releases
13
+ * the authorization code when the loopback redirect is reachable, which never
14
+ * happens on a remote host. A local helper (the `dsh-agy login --remote` path)
15
+ * runs the OAuth on the user's own machine, encodes the raw token response into
16
+ * a single-line blob with this codec, and the remote harness decodes it.
17
+ *
18
+ * Format: `dsh-agy-cred-v1.` + base64url(JSON{v, provider, tokens}) — URL/shell
19
+ * safe, survives copy-paste through terminals.
20
+ */
21
+ const CREDENTIAL_BLOB_PREFIX = "dsh-agy-cred-v1.";
22
+ const CREDENTIAL_BLOB_VERSION = 1;
23
+ /** Encode a provider + raw OAuth token response into a single-line blob. */
24
+ function encodeCredentialBlob(provider, tokens) {
25
+ if (!provider || !provider.trim()) throw new Error("encodeCredentialBlob: a non-empty provider is required");
26
+ if (!tokens || typeof tokens !== "object" || !tokens.access_token) throw new Error("encodeCredentialBlob: tokens with an access_token are required");
27
+ const payload = {
28
+ v: CREDENTIAL_BLOB_VERSION,
29
+ provider: provider.trim(),
30
+ tokens
31
+ };
32
+ const json = JSON.stringify(payload);
33
+ return `${CREDENTIAL_BLOB_PREFIX}${Buffer.from(json, "utf8").toString("base64url")}`;
34
+ }
35
+ /**
36
+ * Decode and validate a credential blob. Throws on wrong prefix, malformed
37
+ * payload, version mismatch, provider mismatch, or missing access_token.
38
+ */
39
+ function decodeCredentialBlob(blob) {
40
+ if (typeof blob !== "string" || !blob.startsWith("dsh-agy-cred-v1.")) throw new Error(`decodeCredentialBlob: invalid format — must start with "${CREDENTIAL_BLOB_PREFIX}"`);
41
+ const b64 = blob.slice(16).trim();
42
+ if (!/^[A-Za-z0-9_-]+$/.test(b64)) throw new Error("decodeCredentialBlob: invalid payload — not base64url");
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(Buffer.from(b64, "base64url").toString("utf8"));
46
+ } catch {
47
+ throw new Error("decodeCredentialBlob: payload is not valid JSON");
48
+ }
49
+ if (!parsed || typeof parsed !== "object") throw new Error("decodeCredentialBlob: payload is not an object");
50
+ if (parsed.v !== CREDENTIAL_BLOB_VERSION) throw new Error(`decodeCredentialBlob: unsupported blob version ${String(parsed.v)}`);
51
+ if (parsed.provider !== "agy") throw new Error(`decodeCredentialBlob: provider mismatch — blob is for "${String(parsed.provider)}" but this plugin serves "agy"`);
52
+ const tokens = parsed.tokens;
53
+ if (!tokens || typeof tokens !== "object") throw new Error("decodeCredentialBlob: tokens are missing");
54
+ if (typeof tokens.access_token !== "string") throw new Error("decodeCredentialBlob: access_token is missing");
55
+ return {
56
+ provider: parsed.provider,
57
+ tokens
58
+ };
59
+ }
60
+ //#endregion
61
+ export { decodeCredentialBlob as n, encodeCredentialBlob as r, blob_exports as t };
62
+
63
+ //# sourceMappingURL=blob-D1e7_uT1.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blob-D1e7_uT1.mjs","names":[],"sources":["../src/oauth/blob.ts"],"sourcesContent":["/**\n * Paste-safe credential blob codec for remote login.\n *\n * Google's `firstparty/nativeapp` consent for this embedded client only releases\n * the authorization code when the loopback redirect is reachable, which never\n * happens on a remote host. A local helper (the `dsh-agy login --remote` path)\n * runs the OAuth on the user's own machine, encodes the raw token response into\n * a single-line blob with this codec, and the remote harness decodes it.\n *\n * Format: `dsh-agy-cred-v1.` + base64url(JSON{v, provider, tokens}) — URL/shell\n * safe, survives copy-paste through terminals.\n */\n\nexport const CREDENTIAL_BLOB_PREFIX = 'dsh-agy-cred-v1.'\n\nconst CREDENTIAL_BLOB_VERSION = 1\n\n/** Provider this blob codec accepts (bound at decode time to prevent replay across providers). */\nexport const CREDENTIAL_BLOB_PROVIDER = 'agy'\n\nexport interface CredentialBlobTokens {\n access_token?: string\n refresh_token?: string\n id_token?: string\n expires_in?: number\n scope?: string\n [key: string]: unknown\n}\n\nexport interface CredentialBlob {\n provider: string\n tokens: CredentialBlobTokens\n}\n\n/** Encode a provider + raw OAuth token response into a single-line blob. */\nexport function encodeCredentialBlob(provider: string, tokens: CredentialBlobTokens): string {\n if (!provider || !provider.trim()) {\n throw new Error('encodeCredentialBlob: a non-empty provider is required')\n }\n if (!tokens || typeof tokens !== 'object' || !tokens.access_token) {\n throw new Error('encodeCredentialBlob: tokens with an access_token are required')\n }\n const payload = { v: CREDENTIAL_BLOB_VERSION, provider: provider.trim(), tokens }\n const json = JSON.stringify(payload)\n return `${CREDENTIAL_BLOB_PREFIX}${Buffer.from(json, 'utf8').toString('base64url')}`\n}\n\n/**\n * Decode and validate a credential blob. Throws on wrong prefix, malformed\n * payload, version mismatch, provider mismatch, or missing access_token.\n */\nexport function decodeCredentialBlob(blob: string): CredentialBlob {\n if (typeof blob !== 'string' || !blob.startsWith(CREDENTIAL_BLOB_PREFIX)) {\n throw new Error(`decodeCredentialBlob: invalid format — must start with \"${CREDENTIAL_BLOB_PREFIX}\"`)\n }\n const b64 = blob.slice(CREDENTIAL_BLOB_PREFIX.length).trim()\n if (!/^[A-Za-z0-9_-]+$/.test(b64)) {\n throw new Error('decodeCredentialBlob: invalid payload — not base64url')\n }\n\n let parsed: { v?: unknown; provider?: unknown; tokens?: unknown }\n try {\n parsed = JSON.parse(Buffer.from(b64, 'base64url').toString('utf8')) as typeof parsed\n } catch {\n throw new Error('decodeCredentialBlob: payload is not valid JSON')\n }\n\n if (!parsed || typeof parsed !== 'object') {\n throw new Error('decodeCredentialBlob: payload is not an object')\n }\n if (parsed.v !== CREDENTIAL_BLOB_VERSION) {\n throw new Error(`decodeCredentialBlob: unsupported blob version ${String(parsed.v)}`)\n }\n if (parsed.provider !== CREDENTIAL_BLOB_PROVIDER) {\n throw new Error(\n `decodeCredentialBlob: provider mismatch — blob is for \"${String(parsed.provider)}\" but this plugin serves \"${CREDENTIAL_BLOB_PROVIDER}\"`,\n )\n }\n const tokens = parsed.tokens\n if (!tokens || typeof tokens !== 'object') {\n throw new Error('decodeCredentialBlob: tokens are missing')\n }\n if (typeof (tokens as CredentialBlobTokens).access_token !== 'string') {\n throw new Error('decodeCredentialBlob: access_token is missing')\n }\n\n return { provider: parsed.provider, tokens: tokens as CredentialBlobTokens }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAaA,MAAa,yBAAyB;AAEtC,MAAM,0BAA0B;;AAoBhC,SAAgB,qBAAqB,UAAkB,QAAsC;CAC3F,IAAI,CAAC,YAAY,CAAC,SAAS,KAAK,GAC9B,MAAM,IAAI,MAAM,wDAAwD;CAE1E,IAAI,CAAC,UAAU,OAAO,WAAW,YAAY,CAAC,OAAO,cACnD,MAAM,IAAI,MAAM,gEAAgE;CAElF,MAAM,UAAU;EAAE,GAAG;EAAyB,UAAU,SAAS,KAAK;EAAG;CAAO;CAChF,MAAM,OAAO,KAAK,UAAU,OAAO;CACnC,OAAO,GAAG,yBAAyB,OAAO,KAAK,MAAM,MAAM,CAAC,CAAC,SAAS,WAAW;AACnF;;;;;AAMA,SAAgB,qBAAqB,MAA8B;CACjE,IAAI,OAAO,SAAS,YAAY,CAAC,KAAK,WAAA,kBAAiC,GACrE,MAAM,IAAI,MAAM,2DAA2D,uBAAuB,EAAE;CAEtG,MAAM,MAAM,KAAK,MAAM,EAA6B,CAAC,CAAC,KAAK;CAC3D,IAAI,CAAC,mBAAmB,KAAK,GAAG,GAC9B,MAAM,IAAI,MAAM,uDAAuD;CAGzE,IAAI;CACJ,IAAI;EACF,SAAS,KAAK,MAAM,OAAO,KAAK,KAAK,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC;CACpE,QAAQ;EACN,MAAM,IAAI,MAAM,iDAAiD;CACnE;CAEA,IAAI,CAAC,UAAU,OAAO,WAAW,UAC/B,MAAM,IAAI,MAAM,gDAAgD;CAElE,IAAI,OAAO,MAAM,yBACf,MAAM,IAAI,MAAM,kDAAkD,OAAO,OAAO,CAAC,GAAG;CAEtF,IAAI,OAAO,aAAA,OACT,MAAM,IAAI,MACR,0DAA0D,OAAO,OAAO,QAAQ,EAAE,+BACpF;CAEF,MAAM,SAAS,OAAO;CACtB,IAAI,CAAC,UAAU,OAAO,WAAW,UAC/B,MAAM,IAAI,MAAM,0CAA0C;CAE5D,IAAI,OAAQ,OAAgC,iBAAiB,UAC3D,MAAM,IAAI,MAAM,+CAA+C;CAGjE,OAAO;EAAE,UAAU,OAAO;EAAkB;CAA+B;AAC7E"}
@@ -0,0 +1,10 @@
1
+ import { Command } from "commander";
2
+ //#region src/oauth/constants.d.ts
3
+ /** Default loopback callback used by the standalone CLI listener (fixed port, like opencode). */
4
+ declare const AGY_DEFAULT_REDIRECT_URI = "http://localhost:51121/oauth-callback";
5
+ //#endregion
6
+ //#region src/cli/index.d.ts
7
+ declare function createProgram(): Command;
8
+ //#endregion
9
+ export { AGY_DEFAULT_REDIRECT_URI, createProgram };
10
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/oauth/constants.ts","../../src/cli/index.ts"],"mappings":";;;cA6Ba;;;iBC8NG,iBAAiB"}
@@ -0,0 +1,317 @@
1
+ import { c as loadMasterKey, d as resolveMasterKeyCodec, i as JsonAccountStore, o as createAesGcmCodec, s as deriveKey, t as AgySessionManager, u as resolveDshHome, y as AGY_DEFAULT_REDIRECT_URI } from "../session-tawi2_uh.mjs";
2
+ import { t as exchangeAntigravity } from "../exchange-MhFUiBAm.mjs";
3
+ import { n as upsertImportedAccount, r as authorizeAntigravity, t as parseImportSource } from "../import-DnKzgr-0.mjs";
4
+ import { r as encodeCredentialBlob } from "../blob-D1e7_uT1.mjs";
5
+ import { readFileSync } from "node:fs";
6
+ import { Command } from "commander";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { stdin, stdout } from "node:process";
9
+ import { createServer } from "node:http";
10
+ //#region src/cli/callback-server.ts
11
+ /** Loopback OAuth callback server for the standalone CLI login flow. */
12
+ const SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Login successful</title></head>
13
+ <body style="font-family:system-ui;text-align:center;padding:3rem">
14
+ <h2>✓ Authentication successful</h2>
15
+ <p>You can close this tab and return to the terminal.</p>
16
+ </body></html>`;
17
+ /** Listen on 127.0.0.1:<port>/oauth-callback and resolve with the code+state. */
18
+ function startCallbackServer(options = {}) {
19
+ const port = options.port ?? 51121;
20
+ const timeoutMs = options.timeoutMs ?? 3e5;
21
+ let resolveResult;
22
+ let rejectResult;
23
+ const result = new Promise((resolve, reject) => {
24
+ resolveResult = resolve;
25
+ rejectResult = reject;
26
+ });
27
+ let resolveReady;
28
+ let rejectReady;
29
+ const ready = new Promise((resolve, reject) => {
30
+ resolveReady = resolve;
31
+ rejectReady = reject;
32
+ });
33
+ const server = createServer((req, res) => {
34
+ const url = new URL(req.url ?? "/", "http://localhost");
35
+ if (url.pathname === "/oauth-callback") {
36
+ const code = url.searchParams.get("code");
37
+ const state = url.searchParams.get("state");
38
+ if (!code || !state) {
39
+ res.writeHead(400, { "Content-Type": "text/html" });
40
+ res.end("<h2>Missing code or state</h2>");
41
+ return;
42
+ }
43
+ res.writeHead(200, {
44
+ "Content-Type": "text/html",
45
+ connection: "close"
46
+ });
47
+ res.end(SUCCESS_HTML);
48
+ resolveResult({
49
+ code,
50
+ state,
51
+ url: url.toString()
52
+ });
53
+ setTimeout(() => server.close(), 1500);
54
+ return;
55
+ }
56
+ res.writeHead(404);
57
+ res.end("Not found");
58
+ });
59
+ server.on("error", (error) => {
60
+ if (error.code === "EADDRINUSE") rejectResult(/* @__PURE__ */ new Error(`Port ${port} is already in use — pass --port to pick another.`));
61
+ else rejectResult(error);
62
+ rejectReady(error);
63
+ });
64
+ server.listen(port, "127.0.0.1", () => {
65
+ resolveReady();
66
+ });
67
+ const timeout = setTimeout(() => {
68
+ rejectResult(/* @__PURE__ */ new Error("Authentication timed out — no callback received."));
69
+ server.close();
70
+ }, timeoutMs);
71
+ return {
72
+ result,
73
+ ready,
74
+ async close() {
75
+ clearTimeout(timeout);
76
+ if (server.listening) {
77
+ server.closeAllConnections();
78
+ await new Promise((resolve) => server.close(() => resolve()));
79
+ }
80
+ }
81
+ };
82
+ }
83
+ /** Open a URL in the system browser (best effort). */
84
+ async function openBrowser(url) {
85
+ const { spawn } = await import("node:child_process");
86
+ const platform = process.platform;
87
+ const command = platform === "darwin" ? ["open", url] : platform === "win32" ? [
88
+ "cmd",
89
+ "/c",
90
+ "start",
91
+ "",
92
+ url
93
+ ] : ["xdg-open", url];
94
+ return new Promise((resolve) => {
95
+ const child = spawn(command[0], command.slice(1), { stdio: "ignore" });
96
+ child.on("error", () => resolve(false));
97
+ child.on("spawn", () => resolve(true));
98
+ });
99
+ }
100
+ //#endregion
101
+ //#region src/cli/index.ts
102
+ /**
103
+ * dsh-agy CLI: login / status / import / verify / logout.
104
+ * Standalone bin — the dsh launcher has no plugin subcommand slot, and the
105
+ * remote paste-blob flow must run on machines without a harness.
106
+ */
107
+ /** Package version, read from the shipped package.json — never hard-coded twice. */
108
+ const { version: PACKAGE_VERSION } = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
109
+ function createStore(options = {}) {
110
+ const dshHome = resolveDshHome();
111
+ let codec;
112
+ if (options.readOnly) {
113
+ const masterKey = loadMasterKey(dshHome);
114
+ if (!masterKey) throw new Error("No agy account store found — run `dsh-agy login` first.");
115
+ codec = createAesGcmCodec(deriveKey(masterKey));
116
+ } else codec = resolveMasterKeyCodec(dshHome).codec;
117
+ return new JsonAccountStore({
118
+ file: `${dshHome}/agy-accounts.json`,
119
+ codec
120
+ });
121
+ }
122
+ /** Read-only store with a friendly error when no credentials exist yet. */
123
+ function createReadOnlyStoreOrExit() {
124
+ try {
125
+ return createStore({ readOnly: true });
126
+ } catch (error) {
127
+ console.error(error instanceof Error ? error.message : String(error));
128
+ process.exit(1);
129
+ }
130
+ }
131
+ async function ask(question) {
132
+ const rl = createInterface({
133
+ input: stdin,
134
+ output: stdout
135
+ });
136
+ try {
137
+ return (await rl.question(question)).trim();
138
+ } finally {
139
+ rl.close();
140
+ }
141
+ }
142
+ /** Remote/SSH sessions cannot reach a local browser; auto-select headless paste. */
143
+ function isRemoteSession() {
144
+ return !!(process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY);
145
+ }
146
+ async function loginCommand(options) {
147
+ const store = createStore();
148
+ const redirectUri = `http://localhost:${options.port}/oauth-callback`;
149
+ if (!options.headless && isRemoteSession()) {
150
+ console.log("(SSH session detected — using headless paste flow; pass --headless explicitly to force)");
151
+ options.headless = true;
152
+ }
153
+ let callback;
154
+ if (!options.headless) {
155
+ callback = startCallbackServer({
156
+ port: options.port,
157
+ timeoutMs: Number(options.timeout) || 3e5
158
+ });
159
+ try {
160
+ await callback.ready;
161
+ } catch (error) {
162
+ console.error(error instanceof Error ? error.message : String(error));
163
+ process.exit(1);
164
+ }
165
+ }
166
+ const { url, verifier } = await authorizeAntigravity(redirectUri, options.project ?? "");
167
+ console.log(`\nOpen this URL in a browser to authorize:\n\n ${url}\n`);
168
+ let code;
169
+ let state;
170
+ if (options.headless) {
171
+ const pasted = await ask("After approving, paste the full redirected URL here: ");
172
+ const parsed = new URL(pasted);
173
+ code = parsed.searchParams.get("code") ?? "";
174
+ state = parsed.searchParams.get("state") ?? "";
175
+ if (!code || !state) {
176
+ console.error("Error: pasted URL is missing code or state.");
177
+ process.exit(1);
178
+ }
179
+ } else {
180
+ if (!await openBrowser(url)) console.log("(Could not open a browser automatically — open the URL manually.)");
181
+ let callbackResult;
182
+ try {
183
+ callbackResult = await callback.result;
184
+ } catch (error) {
185
+ console.error(error instanceof Error ? error.message : String(error));
186
+ process.exit(1);
187
+ }
188
+ code = callbackResult.code;
189
+ state = callbackResult.state;
190
+ }
191
+ const result = await exchangeAntigravity(code, state, redirectUri, verifier);
192
+ if (result.type === "failed") {
193
+ console.error(`Login failed: ${result.error}`);
194
+ process.exit(1);
195
+ }
196
+ if (options.blob) {
197
+ const blob = encodeCredentialBlob("agy", {
198
+ access_token: result.access,
199
+ refresh_token: result.refresh.split("|")[0],
200
+ expires_in: Math.max(0, Math.round((result.expires - Date.now()) / 1e3))
201
+ });
202
+ console.log(`\nPaste this blob into the remote dashboard/CLI:\n\n${blob}\n`);
203
+ } else {
204
+ const { account, created } = await upsertImportedAccount(store, {
205
+ accessToken: result.access,
206
+ refreshToken: result.refresh.split("|")[0],
207
+ tokenType: "Bearer",
208
+ expiresAt: new Date(result.expires).toISOString(),
209
+ authMethod: "oauth",
210
+ email: result.email ?? null,
211
+ projectId: result.projectId || null
212
+ }, { overwriteExisting: true });
213
+ console.log(`${created ? "Added" : "Updated"} account: ${account.email ?? "(no email)"} (project: ${result.projectId || "default"})`);
214
+ }
215
+ await callback?.close();
216
+ }
217
+ async function statusCommand() {
218
+ const store = createReadOnlyStoreOrExit();
219
+ const storage = await store.load();
220
+ if (storage.accounts.length === 0) {
221
+ console.log("No agy accounts. Run `dsh-agy login` first.");
222
+ return;
223
+ }
224
+ console.log(`\n${storage.accounts.length} account(s), active index ${storage.activeIndex}:\n`);
225
+ const sessions = new AgySessionManager({ store });
226
+ for (const [index, account] of storage.accounts.entries()) {
227
+ const marker = index === storage.activeIndex ? "★" : " ";
228
+ const state = account.enabled === false ? "disabled" : account.verificationRequired ? "verification-required" : account.coolingDownUntil && account.coolingDownUntil > Date.now() ? "cooling" : "active";
229
+ console.log(` ${marker} [${index}] ${account.email ?? "(no email)"} — ${state}${account.projectId ? ` (project: ${account.projectId})` : ""}`);
230
+ const session = await sessions.getSession().catch(() => void 0);
231
+ if (session && session.index === index) try {
232
+ const { fetchAvailableModels } = await import("../models-Cu1g7AXV.mjs").then((n) => n.n);
233
+ const discovered = await fetchAvailableModels(session.auth.access, session.account.projectId);
234
+ const entries = Object.entries(discovered.models ?? {});
235
+ if (entries.length > 0) {
236
+ const withQuota = entries.map(([id, entry]) => ({
237
+ id,
238
+ ...entry.quotaInfo
239
+ })).filter((e) => typeof e.remainingFraction === "number").sort((a, b) => (a.remainingFraction ?? 0) - (b.remainingFraction ?? 0));
240
+ if (withQuota.length > 0) {
241
+ const lowest = withQuota[0];
242
+ console.log(` models: ${entries.length}, lowest quota: ${Math.round((lowest.remainingFraction ?? 0) * 100)}% (${lowest.id})${lowest.resetTime ? `, resets ${new Date(lowest.resetTime).toISOString()}` : ""}`);
243
+ } else console.log(` models: ${entries.length} (no per-model quota reported)`);
244
+ }
245
+ } catch (error) {
246
+ console.log(` quota: unavailable (${error instanceof Error ? error.message : String(error)})`);
247
+ }
248
+ }
249
+ }
250
+ async function importCommand(options) {
251
+ const store = createStore();
252
+ let source;
253
+ if (options.file) source = JSON.parse(readFileSync(options.file, "utf8"));
254
+ else source = await ask("Paste the agy token JSON (or blob with --blob): ");
255
+ const kind = options.blob ? "blob" : "json";
256
+ const enriched = await parseImportSource(source, kind);
257
+ const { account, created } = await upsertImportedAccount(store, enriched, {
258
+ email: options.email,
259
+ overwriteExisting: options.overwrite
260
+ });
261
+ console.log(`${created ? "Imported" : "Replaced"} account: ${account.email ?? "(no email)"}${enriched.projectId ? ` (project: ${enriched.projectId})` : ""}`);
262
+ }
263
+ async function verifyCommand(options) {
264
+ const store = createReadOnlyStoreOrExit();
265
+ const sessions = new AgySessionManager({ store });
266
+ const storage = await store.load();
267
+ const indices = options.index !== void 0 ? [Number(options.index)] : storage.accounts.map((_, i) => i);
268
+ for (const index of indices) {
269
+ const account = storage.accounts[index];
270
+ if (!account) {
271
+ console.log(`[${index}] not found`);
272
+ continue;
273
+ }
274
+ const result = await sessions.verifyAccount(index);
275
+ if (result.ok) console.log(`[${index}] ${account.email ?? ""} — OK${result.email && result.email !== account.email ? ` (userinfo: ${result.email})` : ""}`);
276
+ else console.log(`[${index}] ${account.email ?? ""} — FAILED: ${result.error}`);
277
+ }
278
+ }
279
+ async function logoutCommand(options) {
280
+ await createReadOnlyStoreOrExit().mutate((storage) => {
281
+ const index = options.index !== void 0 ? Number(options.index) : options.email ? storage.accounts.findIndex((a) => a.email?.toLowerCase() === options.email.toLowerCase()) : storage.activeIndex;
282
+ if (index < 0 || index >= storage.accounts.length) throw new Error(`account not found (index ${index})`);
283
+ const [removed] = storage.accounts.splice(index, 1);
284
+ if (storage.activeIndex >= storage.accounts.length) storage.activeIndex = 0;
285
+ console.log(`Removed account: ${removed?.email ?? `[${index}]`}`);
286
+ });
287
+ }
288
+ function createProgram() {
289
+ const program = new Command();
290
+ program.name("dsh-agy").description("Google Antigravity (agy) account management for DeepSeek Harness").version(PACKAGE_VERSION);
291
+ program.command("login").description("OAuth login (browser, or headless paste)").option("--headless", "print the URL and wait for a pasted redirect URL", false).option("--blob", "print a paste-credential blob instead of storing the account", false).option("--port <n>", "loopback callback port", "51121").option("--project <id>", "bind the login to a specific project").option("--timeout <ms>", "callback timeout", "300000").action(async (options) => {
292
+ await loginCommand({
293
+ ...options,
294
+ timeout: options.timeout
295
+ });
296
+ });
297
+ program.command("status").description("List accounts and their health").action(async () => {
298
+ await statusCommand();
299
+ });
300
+ program.command("import").description("Import an agy token file or paste credentials").argument("[file]", "path to an agy auth.json token file").option("--blob", "the pasted value is a credential blob", false).option("--email <email>", "account email (skips userinfo verification)").option("--overwrite", "replace an existing account with the same email", false).action(async (file, options) => {
301
+ await importCommand({
302
+ ...options,
303
+ file
304
+ });
305
+ });
306
+ program.command("verify").description("Verify account credentials (refresh + userinfo)").option("--index <n>", "verify one account by index; default all").action(async (options) => {
307
+ await verifyCommand(options);
308
+ });
309
+ program.command("logout").description("Remove an account").option("--index <n>", "account index (default: active)").option("--email <email>", "account email").action(async (options) => {
310
+ await logoutCommand(options);
311
+ });
312
+ return program;
313
+ }
314
+ //#endregion
315
+ export { AGY_DEFAULT_REDIRECT_URI, createProgram };
316
+
317
+ //# sourceMappingURL=index.mjs.map