Editor extension 0.1.1: token sign-in through the editor's own sign-in, so the first clone doesn't ask to allow it again
Found in the live check on diffinery.com: after Diffinery: Sign In with an Access Token, the first clone asked 'The extension Diffinery wants to sign in using Diffinery'. The smoke test now signs in with a token first, in a fresh editor, and fails on 0.1.0. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ3j7radTeNPDXuJ936W3K
- CI / Typecheck and bundlesuccess
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)success
- End-to-end / End-to-end (agent reviews, tasks, Today)Failed
- End-to-end / End-to-end (git, PRs, Actions)Failed
- End-to-end / End-to-end (large pushes)Failed
- End-to-end / End-to-end (passkeys, registry, email)Failed
- End-to-end / End-to-end (real histories)Failed
- End-to-end / End-to-end (runner Docker)Failed
- End-to-end / End-to-end (security)Failed
4 files changed+75−15
Files (4)
modifiedapps/diffinery/test/e2e/editor-smoke.mjs+35−1View file
| @@ -32,6 +32,7 @@ | ||
| 32 | 32 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 33 | 33 | const ADA_SESSION = "ada-editor-session-0123456789abcdef"; |
| 34 | 34 | const ADA_TOKEN = `dfy_${randomBytes(24).toString("hex")}`; |
| 35 | +const CHARLES_TOKEN = `dfy_${randomBytes(24).toString("hex")}`; | |
| 35 | 36 | let devLog = ""; |
| 36 | 37 | |
| 37 | 38 | const freePort = () => |
| @@ -68,6 +69,9 @@ | ||
| 68 | 69 | `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('ada1', 'ada', 'user', 'Ada', 'ada@example.com', 'active', '${at}')`, |
| 69 | 70 | `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(ADA_SESSION)}', 'ada1', 'passkey', '${at}', '${at}', '${later}')`, |
| 70 | 71 | `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('t1', 'ada1', 'e2e', '${hash(ADA_TOKEN)}', '${ADA_TOKEN.slice(0, 10)}', '["admin"]', '${later}', '${at}')`, |
| 72 | + // Charles signs in with an access token he pastes, in step 0. | |
| 73 | + `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('cb1', 'charles', 'user', 'Charles', 'charles@example.com', 'active', '${at}')`, | |
| 74 | + `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('t2', 'cb1', 'e2e', '${hash(CHARLES_TOKEN)}', '${CHARLES_TOKEN.slice(0, 10)}', '["write"]', '${later}', '${at}')`, | |
| 71 | 75 | ].join("; "), |
| 72 | 76 | ); |
| 73 | 77 | const dev = spawn( |
| @@ -89,6 +93,7 @@ | ||
| 89 | 93 | const api = (method, path, body) => fetch(`${base}/api/v3${path}`, { method, headers: { authorization: `Bearer ${ADA_TOKEN}`, "content-type": "application/json" }, body: body && JSON.stringify(body) }).then(async (r) => (r.ok ? r.json() : Promise.reject(new Error(`${method} ${path}: ${r.status} ${await r.text()}`)))); |
| 90 | 94 | await api("POST", "/user/repos", { name: "hello-editor", private: true, description: "The analytical engine, in TypeScript" }); |
| 91 | 95 | await api("POST", "/user/repos", { name: "notes", private: false, description: "Notes on the engine, and drafts of the next paper" }); |
| 96 | +await fetch(`${base}/api/v3/user/repos`, { method: "POST", headers: { authorization: `Bearer ${CHARLES_TOKEN}`, "content-type": "application/json" }, body: JSON.stringify({ name: "engine-drawings", private: true }) }).then((r) => assert.ok(r.ok, `charles's repository: ${r.status}`)); | |
| 92 | 97 | const seed = join(work, "seed"); |
| 93 | 98 | mkdirSync(seed); |
| 94 | 99 | const git = (cwd, ...args) => execFileSync("git", args, { cwd, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }).toString(); |
| @@ -165,11 +170,39 @@ | ||
| 165 | 170 | await win.keyboard.press("Control+Shift+P"); |
| 166 | 171 | await win.waitForSelector(".quick-input-widget:not([style*='display: none']) .quick-input-box input"); |
| 167 | 172 | await win.keyboard.type(cmd); |
| 168 | - await sleep(500); | |
| 173 | + // The palette filters as it's typed: run the command only once it's the one highlighted. | |
| 174 | + await until(`"${cmd}" highlighted in the Command Palette`, async () => (await win.textContent(".quick-input-list .monaco-list-row.focused").catch(() => ""))?.includes(cmd), 10_000); | |
| 169 | 175 | await win.keyboard.press("Enter"); |
| 170 | 176 | }; |
| 171 | 177 | const quickItems = async () => (await win.$$eval(".quick-input-list .monaco-list-row", (rows) => rows.map((r) => r.textContent?.trim() ?? ""))).filter(Boolean); |
| 172 | 178 | |
| 179 | +// 0. Signing in with a pasted token, in a fresh editor, goes through the editor's own sign-in: the editor asks once, | |
| 180 | +// up front, and the clone picker then lists the account's repositories without asking again. Then sign out again. | |
| 181 | +await palette("Diffinery: Sign In with an Access Token"); | |
| 182 | +const allowToken = await until("the sign-in prompt", async () => win.$(".monaco-dialog-box .monaco-button:has-text('Allow')"), 30_000); | |
| 183 | +await shot(win, "0-allow-token"); | |
| 184 | +await allowToken.click(); | |
| 185 | +await win.waitForSelector(".quick-input-widget:not([style*='display: none']) .quick-input-box input"); | |
| 186 | +await sleep(300); | |
| 187 | +await win.fill(".quick-input-widget:not([style*='display: none']) .quick-input-box input", CHARLES_TOKEN); | |
| 188 | +await win.keyboard.press("Enter"); | |
| 189 | +await until("the signed-in notice", async () => (await win.textContent(".notifications-toasts").catch(() => ""))?.includes("Signed in to Diffinery as charles"), 30_000); | |
| 190 | +await palette("Git: Clone"); | |
| 191 | +await until("Clone from Diffinery in the picker", async () => (await quickItems()).some((t) => t.includes("Clone from Diffinery")), 30_000); | |
| 192 | +await win.keyboard.type("Clone from Diffinery"); | |
| 193 | +await sleep(300); | |
| 194 | +await win.keyboard.press("Enter"); | |
| 195 | +await until("charles's repositories", async () => { | |
| 196 | + if (await win.$(".monaco-dialog-box")) throw new Error("the editor asked again to allow the sign-in"); | |
| 197 | + return (await quickItems()).some((t) => t.startsWith("charles/engine-drawings")); | |
| 198 | +}, 30_000); | |
| 199 | +await win.keyboard.press("Escape"); | |
| 200 | +console.log("token sign-in: charles's repositories listed, no second prompt"); | |
| 201 | +await sleep(500); | |
| 202 | +await palette("Diffinery: Sign Out"); | |
| 203 | +await until("the signed-out notice", async () => (await win.textContent(".notifications-toasts").catch(() => ""))?.includes("Signed out of Diffinery"), 20_000); | |
| 204 | +await win.keyboard.press("Escape"); | |
| 205 | + | |
| 173 | 206 | // 1. The clone picker lists Diffinery as a remote source. |
| 174 | 207 | await palette("Git: Clone"); |
| 175 | 208 | await until("Clone from Diffinery in the picker", async () => (await quickItems()).some((t) => t.includes("Clone from Diffinery")), 30_000); |
| @@ -282,6 +315,7 @@ | ||
| 282 | 315 | assert.ok(grants.length === 1 && grants[0].revoked_at, "the connection is revoked"); |
| 283 | 316 | console.log("signed out: credential removed, connection revoked"); |
| 284 | 317 | |
| 318 | + | |
| 285 | 319 | await app.close(); |
| 286 | 320 | cleanup(); |
| 287 | 321 | if (process.env.KEEP !== "1") rmSync(work, { recursive: true, force: true }); |
modifiedapps/vscode/CHANGELOG.md+5−0View file
| @@ -1,5 +1,10 @@ | ||
| 1 | 1 | # Changelog |
| 2 | 2 | |
| 3 | +## 0.1.1 | |
| 4 | + | |
| 5 | +- "Sign In with an Access Token" goes through the editor's own sign-in, so the first clone afterwards no longer asks to allow the extension to sign in. | |
| 6 | +- Signing in again replaces the previous sign-in and disconnects it on Diffinery. | |
| 7 | + | |
| 3 | 8 | ## 0.1.0 |
| 4 | 9 | |
| 5 | 10 | - "Clone from Diffinery" in the clone picker of VS Code, Cursor, Windsurf, VSCodium and other editors built on VS Code, listing your repositories and your organizations', with search. |
modifiedapps/vscode/package.json+1−1View file
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@diffinery/vscode", |
| 3 | 3 | "displayName": "Diffinery", |
| 4 | 4 | "description": "Clone, publish and push Diffinery repositories from VS Code, Cursor, Windsurf and other editors built on VS Code.", |
| 5 | - "version": "0.1.0", | |
| 5 | + "version": "0.1.1", | |
| 6 | 6 | "publisher": "codician", |
| 7 | 7 | "private": true, |
| 8 | 8 | "homepage": "https://diffinery.com/docs/repositories/clone-in-your-editor", |
modifiedapps/vscode/src/auth.ts+34−13View file
| @@ -184,12 +184,18 @@ | ||
| 184 | 184 | } |
| 185 | 185 | |
| 186 | 186 | async createSession(): Promise<vscode.AuthenticationSession> { |
| 187 | - const s = await this.signInWithBrowser(); | |
| 187 | + const s = this.withToken ? await this.askForToken() : await this.signInWithBrowser(); | |
| 188 | + // Signing in again replaces the old sign-in, and ends its connection on the server. | |
| 189 | + const old = await this.load(); | |
| 190 | + if (old) await this.removeSession(old.id); | |
| 188 | 191 | await this.save(s); |
| 189 | 192 | void vscode.window.showInformationMessage(`Signed in to Diffinery as ${s.login}.`); |
| 190 | 193 | return toSession(s); |
| 191 | 194 | } |
| 192 | 195 | |
| 196 | + /** Set while "Sign In with an Access Token" runs, so createSession asks for a token instead of opening the browser. */ | |
| 197 | + private withToken = false; | |
| 198 | + | |
| 193 | 199 | async removeSession(id: string): Promise<void> { |
| 194 | 200 | const s = await this.load(); |
| 195 | 201 | if (!s || s.id !== id) return; |
| @@ -283,20 +289,35 @@ | ||
| 283 | 289 | } |
| 284 | 290 | } |
| 285 | 291 | |
| 286 | - /** Signs in with an access token someone pastes (for an agent account, a self-hosted server, or a browser-based editor). */ | |
| 292 | + /** | |
| 293 | + * Signs in with an access token someone pastes (for an agent account, a self-hosted server, or a browser-based | |
| 294 | + * editor). It goes through the editor's own sign-in, like the browser sign-in, so the editor records that this | |
| 295 | + * extension may use the account and doesn't ask again on the first clone. | |
| 296 | + */ | |
| 287 | 297 | async signInWithToken(): Promise<void> { |
| 298 | + const signedIn = await this.load(); | |
| 299 | + this.withToken = true; | |
| 300 | + try { | |
| 301 | + await vscode.authentication.getSession(AUTH_ID, SCOPES, signedIn ? { forceNewSession: { detail: `Replace ${signedIn.login}'s sign-in with an access token.` } } : { createIfNone: true }); | |
| 302 | + } finally { | |
| 303 | + this.withToken = false; | |
| 304 | + } | |
| 305 | + } | |
| 306 | + | |
| 307 | + private async askForToken(): Promise<Stored> { | |
| 288 | 308 | const server = this.server(); |
| 289 | - const token = await vscode.window.showInputBox({ | |
| 290 | - title: "Sign in to Diffinery with an access token", | |
| 291 | - prompt: `Paste a token from ${server}/settings/tokens (write access lets you push). It stays in the editor's secret storage.`, | |
| 292 | - password: true, | |
| 293 | - ignoreFocusOut: true, | |
| 294 | - validateInput: (v) => (/^\s*\S+\s*$/.test(v) ? null : "Paste the whole token, without spaces."), | |
| 295 | - }); | |
| 296 | - if (!token) return; | |
| 297 | - const user = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "Checking the token with Diffinery…" }, () => new Api(server, token.trim(), this.version).user()); | |
| 298 | - await this.save({ id: randomUUID(), server, login: user.login, accountId: String(user.id), kind: "token", accessToken: token.trim() }); | |
| 299 | - void vscode.window.showInformationMessage(`Signed in to Diffinery as ${user.login}.`); | |
| 309 | + const token = ( | |
| 310 | + await vscode.window.showInputBox({ | |
| 311 | + title: "Sign in to Diffinery with an access token", | |
| 312 | + prompt: `Paste a token from ${server}/settings/tokens (write access lets you push). It stays in the editor's secret storage.`, | |
| 313 | + password: true, | |
| 314 | + ignoreFocusOut: true, | |
| 315 | + validateInput: (v) => (/^\s*\S+\s*$/.test(v) ? null : "Paste the whole token, without spaces."), | |
| 316 | + }) | |
| 317 | + )?.trim(); | |
| 318 | + if (!token) throw new vscode.CancellationError(); | |
| 319 | + const user = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "Checking the token with Diffinery…" }, () => new Api(server, token, this.version).user()); | |
| 320 | + return { id: randomUUID(), server, login: user.login, accountId: String(user.id), kind: "token", accessToken: token }; | |
| 300 | 321 | } |
| 301 | 322 | |
| 302 | 323 | async signOut(): Promise<void> { |