clauderipple 0.6.0 → 0.8.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/CHANGELOG.md +66 -0
- package/README.ko.md +43 -2
- package/README.md +60 -4
- package/dist/app/package.json +1 -1
- package/dist/cli/src/claude-auth.js +35 -1
- package/dist/cli/src/codex.js +23 -0
- package/dist/cli/src/hooks/agent-title.js +58 -29
- package/dist/cli/src/index.js +89 -1
- package/dist/cli/src/runtime.js +3 -0
- package/dist/cli/src/settings.js +49 -0
- package/dist/mod/.claude-plugin/plugin.json +1 -0
- package/dist/mod/hooks/hooks.json +1 -0
- package/dist/mod/hooks/register.tsx +309 -0
- package/dist/mod/hooks/worker.ts +49 -0
- package/dist/mod/tsconfig.json +3 -0
- package/dist/mod/types/index.d.ts +30 -0
- package/dist/router/src/admin.js +497 -34
- package/dist/router/src/agents.js +50 -9
- package/dist/router/src/catalog.js +80 -0
- package/dist/router/src/compat.js +27 -1
- package/dist/router/src/config.js +33 -0
- package/dist/router/src/index.js +13 -1
- package/dist/router/src/ingress/server.js +3 -2
- package/dist/router/src/picker.js +33 -2
- package/dist/router/src/presets.js +2 -1
- package/dist/router/src/providers/chatgpt/index.js +154 -15
- package/dist/router/src/providers/chatgpt/translate.js +34 -6
- package/dist/router/src/providers/google/accounts.js +345 -0
- package/dist/router/src/providers/google/antigravity.js +392 -0
- package/dist/router/src/providers/google/index.js +617 -0
- package/dist/router/src/providers/google/login.js +249 -0
- package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
- package/dist/router/src/providers/google/translate.js +539 -0
- package/dist/router/src/providers/google/transport.js +118 -0
- package/dist/router/src/providers/openai/translate.js +3 -3
- package/dist/router/src/proxy.js +99 -1
- package/dist/router/src/requestlog.js +2 -0
- package/dist/router/src/routing.js +22 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +436 -44
- package/dist/ui/i18n.js +88 -4
- package/dist/ui/index.html +10 -0
- package/dist/ui/style.css +4 -0
- package/docs/ARCHITECTURE.md +300 -3
- package/package.json +1 -1
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Google Antigravity (Cloud Code Assist) sign-in: the OAuth authorization-code flow with PKCE, the
|
|
2
|
+
// project discovery that follows it, and the account it leaves in the store.
|
|
3
|
+
//
|
|
4
|
+
// The loopback listener is preferred on port 51121 (the Antigravity desktop client's), falling back
|
|
5
|
+
// to any free port — Google's installed-app clients accept an arbitrary loopback port, and the
|
|
6
|
+
// redirect_uri in the token exchange is the one actually bound. state and PKCE verifier are per
|
|
7
|
+
// attempt; a callback with the wrong state is refused without ending the attempt; an attempt expires
|
|
8
|
+
// after 5 minutes.
|
|
9
|
+
//
|
|
10
|
+
// Wire facts (authorize params, token body, the userinfo call, project discovery) are taken from the
|
|
11
|
+
// reference implementation opencodex as a behavioral spec (src/oauth/google-antigravity.ts) and are
|
|
12
|
+
// **not measured live** — see docs/ARCHITECTURE.md §4d.
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import net from "node:net";
|
|
16
|
+
import { redactErrorText } from "../../redact.js";
|
|
17
|
+
import { saveGoogleAccount } from "./accounts.js";
|
|
18
|
+
import { discoverProject, fetchUserEmail, GOOGLE_OAUTH } from "./antigravity.js";
|
|
19
|
+
function base64url(buffer) {
|
|
20
|
+
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Exchange an authorization code for a grant. The token endpoint is Google's, form-encoded, with the
|
|
24
|
+
* public client id and secret. Exported so the refresh path and tests can reuse it without a browser.
|
|
25
|
+
*/
|
|
26
|
+
export async function exchangeGoogleCode(opts) {
|
|
27
|
+
const res = await (opts.fetch ?? fetch)(GOOGLE_OAUTH.tokenUrl, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
30
|
+
body: new URLSearchParams({
|
|
31
|
+
grant_type: "authorization_code",
|
|
32
|
+
client_id: GOOGLE_OAUTH.clientId,
|
|
33
|
+
client_secret: GOOGLE_OAUTH.clientSecret,
|
|
34
|
+
code: opts.code,
|
|
35
|
+
redirect_uri: opts.redirectUri,
|
|
36
|
+
code_verifier: opts.codeVerifier,
|
|
37
|
+
}).toString(),
|
|
38
|
+
signal: AbortSignal.timeout(GOOGLE_OAUTH.requestTimeoutMs),
|
|
39
|
+
});
|
|
40
|
+
const text = await res.text().catch(() => "");
|
|
41
|
+
if (!res.ok)
|
|
42
|
+
throw new Error(`Google sign-in was refused by the token endpoint: HTTP ${res.status} ${redactErrorText(text, [opts.code], 300)}`);
|
|
43
|
+
let j;
|
|
44
|
+
try {
|
|
45
|
+
j = JSON.parse(text);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new Error("Google sign-in answered with something other than JSON");
|
|
49
|
+
}
|
|
50
|
+
if (typeof j.access_token !== "string" || j.access_token.length === 0)
|
|
51
|
+
throw new Error("Google sign-in returned no access token");
|
|
52
|
+
if (typeof j.refresh_token !== "string" || j.refresh_token.length === 0) {
|
|
53
|
+
// Without a refresh token every request would work until this one expires and then fail closed.
|
|
54
|
+
throw new Error("Google sign-in returned no refresh token (re-consent with access_type=offline and prompt=consent)");
|
|
55
|
+
}
|
|
56
|
+
const expiresIn = typeof j.expires_in === "number" && Number.isFinite(j.expires_in) && j.expires_in > 0 ? j.expires_in : 3600;
|
|
57
|
+
const now = (opts.now ?? Date.now)();
|
|
58
|
+
const [email, projectId] = await Promise.all([
|
|
59
|
+
fetchUserEmail(j.access_token, opts.fetch ?? fetch),
|
|
60
|
+
discoverProject(j.access_token, opts.fetch ?? fetch),
|
|
61
|
+
]);
|
|
62
|
+
if (!projectId) {
|
|
63
|
+
// A credential with no Cloud Code Assist project would answer every request with an error while
|
|
64
|
+
// the dashboard reads "signed in". Fail the sign-in instead.
|
|
65
|
+
throw new Error("could not discover a Cloud Code Assist project for this account; make sure it has Antigravity/Cloud Code Assist access and try again");
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
accessToken: j.access_token,
|
|
69
|
+
refreshToken: j.refresh_token,
|
|
70
|
+
projectId,
|
|
71
|
+
...(email ? { email } : {}),
|
|
72
|
+
expiresAt: now + expiresIn * 1000,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* One sign-in attempt. `start()` returns the authorize URL; the grant arrives through the loopback
|
|
77
|
+
* callback or `submitCode()`, whichever comes first; `result` resolves when the credential file is
|
|
78
|
+
* written or the attempt failed. A session is single-use.
|
|
79
|
+
*/
|
|
80
|
+
export class GoogleOAuthSession {
|
|
81
|
+
result;
|
|
82
|
+
options;
|
|
83
|
+
verifier = base64url(crypto.randomBytes(32));
|
|
84
|
+
state = crypto.randomBytes(16).toString("hex");
|
|
85
|
+
redirectUri = "";
|
|
86
|
+
server = null;
|
|
87
|
+
server6 = null;
|
|
88
|
+
settle;
|
|
89
|
+
grant;
|
|
90
|
+
timer = null;
|
|
91
|
+
exchanging = false;
|
|
92
|
+
finished = false;
|
|
93
|
+
stateSnapshot;
|
|
94
|
+
constructor(options, warning) {
|
|
95
|
+
this.options = options;
|
|
96
|
+
this.stateSnapshot = { running: false, url: null, startedAt: null, finishedAt: null, ok: null, error: null, account: null, termsWarning: warning };
|
|
97
|
+
this.grant = new Promise((resolve, reject) => {
|
|
98
|
+
this.settle = { resolve, reject };
|
|
99
|
+
});
|
|
100
|
+
this.result = this.grant.then((grant) => {
|
|
101
|
+
const account = saveGoogleAccount(options.home, grant);
|
|
102
|
+
this.stateSnapshot = { ...this.stateSnapshot, running: false, finishedAt: new Date().toISOString(), ok: true, account };
|
|
103
|
+
}, (error) => {
|
|
104
|
+
this.stateSnapshot = { ...this.stateSnapshot, running: false, finishedAt: new Date().toISOString(), ok: false, error: error.message };
|
|
105
|
+
throw error;
|
|
106
|
+
});
|
|
107
|
+
// Nobody may be awaiting `result` (the GUI polls state instead); do not surface it as unhandled.
|
|
108
|
+
this.result.catch(() => { });
|
|
109
|
+
}
|
|
110
|
+
get snapshot() {
|
|
111
|
+
return this.stateSnapshot;
|
|
112
|
+
}
|
|
113
|
+
/** The loopback port in use, once started. */
|
|
114
|
+
port = null;
|
|
115
|
+
/** Starts the loopback listener and returns the URL to open. */
|
|
116
|
+
async start() {
|
|
117
|
+
if (!(await this.listen(this.options.port ?? GOOGLE_OAUTH.port)) && !(await this.listen(0))) {
|
|
118
|
+
throw new Error(`could not open a loopback callback port (tried ${this.options.port ?? GOOGLE_OAUTH.port}); close whatever is using it and try again`);
|
|
119
|
+
}
|
|
120
|
+
this.redirectUri = `http://127.0.0.1:${this.port}${GOOGLE_OAUTH.callbackPath}`;
|
|
121
|
+
const url = new URL(GOOGLE_OAUTH.authorizeUrl);
|
|
122
|
+
url.search = new URLSearchParams({
|
|
123
|
+
response_type: "code",
|
|
124
|
+
client_id: GOOGLE_OAUTH.clientId,
|
|
125
|
+
redirect_uri: this.redirectUri,
|
|
126
|
+
scope: GOOGLE_OAUTH.scopes.join(" "),
|
|
127
|
+
code_challenge: base64url(crypto.createHash("sha256").update(this.verifier).digest()),
|
|
128
|
+
code_challenge_method: "S256",
|
|
129
|
+
access_type: "offline",
|
|
130
|
+
// select_account lets the user pick a different Google account when adding a second one.
|
|
131
|
+
prompt: "consent select_account",
|
|
132
|
+
state: this.state,
|
|
133
|
+
}).toString();
|
|
134
|
+
this.timer = setTimeout(() => this.fail(new Error("Google sign-in timed out; start it again")), GOOGLE_OAUTH.timeoutMs);
|
|
135
|
+
this.timer.unref();
|
|
136
|
+
this.stateSnapshot = { ...this.stateSnapshot, running: true, url: url.toString(), startedAt: new Date().toISOString(), finishedAt: null, ok: null, error: null, account: null };
|
|
137
|
+
return { url: url.toString(), port: this.port };
|
|
138
|
+
}
|
|
139
|
+
/** Accepts `code`, `code#state`, or the full redirect URL the browser landed on. */
|
|
140
|
+
async submitCode(input) {
|
|
141
|
+
if (this.finished || this.exchanging)
|
|
142
|
+
return;
|
|
143
|
+
let code = input.trim();
|
|
144
|
+
let state = null;
|
|
145
|
+
try {
|
|
146
|
+
const u = new URL(code);
|
|
147
|
+
code = u.searchParams.get("code") ?? "";
|
|
148
|
+
state = u.searchParams.get("state");
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
const hash = code.indexOf("#");
|
|
152
|
+
if (hash >= 0) {
|
|
153
|
+
state = code.slice(hash + 1) || null;
|
|
154
|
+
code = code.slice(0, hash);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!code) {
|
|
158
|
+
this.fail(new Error("no authorization code in what was pasted"));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (state !== null && state !== this.state) {
|
|
162
|
+
this.fail(new Error("the pasted code belongs to a different sign-in attempt"));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
await this.exchange(code);
|
|
166
|
+
}
|
|
167
|
+
/** Abandons the attempt (a new one may start). */
|
|
168
|
+
cancel() {
|
|
169
|
+
this.fail(new Error("Google sign-in cancelled"));
|
|
170
|
+
}
|
|
171
|
+
listen(port) {
|
|
172
|
+
return new Promise((resolve) => {
|
|
173
|
+
const handler = (req, res) => {
|
|
174
|
+
const u = new URL(req.url ?? "/", "http://localhost");
|
|
175
|
+
if (u.pathname !== GOOGLE_OAUTH.callbackPath) {
|
|
176
|
+
res.writeHead(404).end();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const error = u.searchParams.get("error");
|
|
180
|
+
const code = u.searchParams.get("code");
|
|
181
|
+
// A request that is not for this attempt (wrong or missing state) is refused but does not end
|
|
182
|
+
// the attempt: anything on the machine can hit a loopback port, and the real redirect may
|
|
183
|
+
// still be on its way.
|
|
184
|
+
if (u.searchParams.get("state") !== this.state || (!error && !code)) {
|
|
185
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Google sign-in: this is not the sign-in ClaudeRipple is waiting for. You can close this tab.");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (error || !code) {
|
|
189
|
+
const detail = redactErrorText(error ?? "missing authorization code", [], 200);
|
|
190
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end(`Google sign-in failed: ${detail}. You can close this tab.`);
|
|
191
|
+
this.fail(new Error(`Google refused the sign-in: ${detail}`));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("ClaudeRipple: Google account connected. You can close this tab.");
|
|
195
|
+
void this.exchange(code);
|
|
196
|
+
};
|
|
197
|
+
const server = http.createServer(handler);
|
|
198
|
+
server.once("error", () => resolve(false));
|
|
199
|
+
server.listen(port, "127.0.0.1", () => {
|
|
200
|
+
this.server = server;
|
|
201
|
+
this.port = server.address().port;
|
|
202
|
+
// The browser resolves "localhost" to ::1 or 127.0.0.1 as it likes; answer on both when IPv6
|
|
203
|
+
// loopback is available. Best effort.
|
|
204
|
+
const six = http.createServer(handler);
|
|
205
|
+
six.once("error", () => { });
|
|
206
|
+
six.listen(this.port, "::1", () => {
|
|
207
|
+
if (this.finished) {
|
|
208
|
+
six.close();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
this.server6 = six;
|
|
212
|
+
});
|
|
213
|
+
resolve(true);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
async exchange(code) {
|
|
218
|
+
if (this.finished || this.exchanging)
|
|
219
|
+
return;
|
|
220
|
+
this.exchanging = true;
|
|
221
|
+
try {
|
|
222
|
+
const grant = await exchangeGoogleCode({ code, codeVerifier: this.verifier, redirectUri: this.redirectUri, ...(this.options.fetch ? { fetch: this.options.fetch } : {}), ...(this.options.now ? { now: this.options.now } : {}) });
|
|
223
|
+
this.finish();
|
|
224
|
+
this.settle.resolve(grant);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
this.fail(error);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
fail(error) {
|
|
231
|
+
if (this.finished)
|
|
232
|
+
return;
|
|
233
|
+
this.finish();
|
|
234
|
+
this.settle.reject(error);
|
|
235
|
+
}
|
|
236
|
+
finish() {
|
|
237
|
+
this.finished = true;
|
|
238
|
+
if (this.timer)
|
|
239
|
+
clearTimeout(this.timer);
|
|
240
|
+
for (const server of [this.server, this.server6]) {
|
|
241
|
+
if (!server)
|
|
242
|
+
continue;
|
|
243
|
+
server.close();
|
|
244
|
+
server.closeAllConnections?.();
|
|
245
|
+
}
|
|
246
|
+
this.server = null;
|
|
247
|
+
this.server6 = null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// thoughtSignature memory for the Google adapter (Gemini 3).
|
|
2
|
+
//
|
|
3
|
+
// Gemini 3 refuses a turn whose replayed functionCall part does not carry the `thoughtSignature`
|
|
4
|
+
// the model attached to that call: the request comes back 400 "Function call is missing a
|
|
5
|
+
// thought_signature in functionCall parts" (measured against Google's OpenAI-compatible endpoint,
|
|
6
|
+
// microsoft/vscode#296713, 2026-10). Claude Code's history carries tool_use blocks, not Gemini
|
|
7
|
+
// parts, so the signature has nowhere to live except here: the adapter stores it when the answer
|
|
8
|
+
// arrives and puts it back on the matching functionCall when the same call is replayed.
|
|
9
|
+
//
|
|
10
|
+
// Keyed by the tool_use id we minted. That is stable for one tool call within a conversation, and
|
|
11
|
+
// two conversations never share an id (the ids are random), so the store needs no further scoping.
|
|
12
|
+
//
|
|
13
|
+
// Bounded LRU: thought signatures are large opaque strings, and an unbounded map would grow for the
|
|
14
|
+
// life of the process.
|
|
15
|
+
/** Upper bound on remembered signatures. Far more than a conversation holds; small enough to forget. */
|
|
16
|
+
const DEFAULT_LIMIT = 10_000;
|
|
17
|
+
export class ThoughtSignatureStore {
|
|
18
|
+
// Insertion order is the LRU order: a get re-inserts, a set past the limit drops the oldest.
|
|
19
|
+
map = new Map();
|
|
20
|
+
limit;
|
|
21
|
+
constructor(limit = DEFAULT_LIMIT) {
|
|
22
|
+
this.limit = limit;
|
|
23
|
+
}
|
|
24
|
+
get(id) {
|
|
25
|
+
const value = this.map.get(id);
|
|
26
|
+
if (value === undefined)
|
|
27
|
+
return undefined;
|
|
28
|
+
// Refresh recency so a signature that keeps being replayed is not evicted under a busy store.
|
|
29
|
+
this.map.delete(id);
|
|
30
|
+
this.map.set(id, value);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
set(id, signature) {
|
|
34
|
+
if (!id || !signature)
|
|
35
|
+
return;
|
|
36
|
+
if (this.map.has(id))
|
|
37
|
+
this.map.delete(id);
|
|
38
|
+
this.map.set(id, signature);
|
|
39
|
+
while (this.map.size > this.limit) {
|
|
40
|
+
const oldest = this.map.keys().next().value;
|
|
41
|
+
if (oldest === undefined)
|
|
42
|
+
break;
|
|
43
|
+
this.map.delete(oldest);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
get size() { return this.map.size; }
|
|
47
|
+
}
|