diff --git a/apps/desktop/electron/aiturk-login.test.ts b/apps/desktop/electron/aiturk-login.test.ts new file mode 100644 index 0000000..3ede9a5 --- /dev/null +++ b/apps/desktop/electron/aiturk-login.test.ts @@ -0,0 +1,605 @@ +/** + * Tests for electron/aiturk-login.ts — the one-click TurkServis sign-in. + * Everything is injected (fake fetch, fake backend, fake CLI, fake clock), so + * no network, browser or real CLI is touched. The fixture "keys" below are + * obviously fake placeholders in the right shape. + */ + +import { EventEmitter } from 'node:events' + +import { describe, expect, it, vi } from 'vitest' + +import { + AITURK_CLI_KEY_HOLD_MS, + type AiturkCliExit, + AiturkLoginError, + createAiturkCliRunner, + createAiturkLogin, + resolveAiturkCliPath +} from './aiturk-login' + +const DEVICE_CODE = `tsdl_${'d'.repeat(43)}` +const IDE_KEY = 'tsk_FAKE-ide-key-for-tests' +const CLI_KEY = 'tsk_FAKE-cli-key-for-tests' +const URI = 'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e' + +const startBody = (over: Record = {}) => ({ + loginId: '0f8fad5b-d9cb-469f-a165-70867728950e', + deviceCode: DEVICE_CODE, + userCode: 'ABCD2345', + expiresIn: 600, + interval: 3, + verificationUri: URI, + ...over +}) + +const approved = { state: 'approved', key: IDE_KEY, cliKey: CLI_KEY, account: 'acct_test' } + +function harness( + options: { + start?: { status: number; body?: unknown } + polls?: ({ status: number; body?: unknown } | Error)[] + cliExits?: AiturkCliExit[] + validate?: unknown + save?: unknown + openExternal?: () => boolean + } = {} +) { + const calls: { url: string; init: any }[] = [] + const polls = [...(options.polls ?? [])] + const cliExits = [...(options.cliExits ?? [0])] + const clock = { now: 1_000_000 } + const timers: { fn: () => void; ms: number; cleared: boolean }[] = [] + + const fetch = vi.fn(async (url: string, init: any) => { + calls.push({ url, init }) + + if (url.endsWith('/start')) { + const r = options.start ?? { status: 201, body: startBody() } + + return { status: r.status, json: async () => r.body } + } + + if (url.endsWith('/poll')) { + const r = polls.shift() ?? { status: 200, body: { state: 'pending' } } + + if (r instanceof Error) { + throw r + } + + return { status: r.status, json: async () => r.body } + } + + return { status: 200, json: async () => ({}) } + }) + + const backendRequest = vi.fn(async (request: any) => { + if (request.path.endsWith('/validate')) { + return options.validate ?? { ok: true, reachable: true, models: ['account/model-a', 'account/model-b'], message: '' } + } + + return options.save ?? { ok: true, id: 'turkservis', endpoints: [] } + }) + + const runCli = vi.fn(async (_key: string, _options: { keepOtherAccount: boolean }) => + cliExits.length ? (cliExits.shift() as AiturkCliExit) : 0 + ) + + const openExternal = vi.fn(options.openExternal ?? (() => true)) + + const login = createAiturkLogin({ + fetch, + openExternal, + hostname: () => 'TEST-PC', + backendRequest, + runCli, + now: () => clock.now, + setTimer: (fn, ms) => { + const timer = { fn, ms, cleared: false } + timers.push(timer) + + return timer + }, + clearTimer: handle => { + ;(handle as { cleared: boolean }).cleared = true + }, + timeoutSignal: () => undefined + }) + + return { login, fetch, calls, backendRequest, runCli, openExternal, clock, timers } +} + +const leaks = (value: unknown) => /tsk_|tsdl_/.test(typeof value === 'string' ? value : JSON.stringify(value) ?? '') + +describe('aiturk login: start', () => { + it('asks the server for a code, opens the approval page and returns no secret', async () => { + const h = harness() + const started = await h.login.start({ profile: 'work' }) + + expect(started).toEqual({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: 1_000_000 + 600_000 }) + expect(leaks(started)).toBe(false) + expect(h.openExternal).toHaveBeenCalledExactlyOnceWith(URI) + + const { url, init } = h.calls[0] + expect(url).toBe('https://turkservis.online/v1/aiturk/device-auth/start') + expect(init.method).toBe('POST') + expect(init.redirect).toBe('error') + expect(JSON.parse(init.body)).toEqual({ client: 'ide', device_name: 'TEST-PC', also_cli: true }) + expect(init.headers.Authorization).toBeUndefined() + }) + + it.each([ + 'https://turkservis.online.evil.example/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e', + 'http://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e', + 'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e?next=//evil', + 'https://turkservis.online/account/aiturk/device-login/../../x', + 'https://evil.example/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e', + '' + ])('refuses a verification URL that is not exactly the approval page: %s', async verificationUri => { + const h = harness({ start: { status: 201, body: startBody({ verificationUri }) } }) + + const error = await h.login.start().catch(e => e) + expect(error).toBeInstanceOf(AiturkLoginError) + expect(error.message).toBe('aiturk-login:invalid-response') + expect(leaks(error.message)).toBe(false) + expect(h.openExternal).not.toHaveBeenCalled() + // The issued code is revoked instead of being left approvable. + expect(h.calls.at(-1)?.url).toBe('https://turkservis.online/v1/aiturk/device-auth/cancel') + expect(await h.login.poll()).toEqual({ state: 'expired' }) + }) + + it('maps 429 to rate-limited and other failures to unavailable', async () => { + await expect(harness({ start: { status: 429 } }).login.start()).rejects.toThrow('aiturk-login:rate-limited') + await expect(harness({ start: { status: 422 } }).login.start()).rejects.toThrow('aiturk-login:unavailable') + await expect(harness({ start: { status: 500 } }).login.start()).rejects.toThrow('aiturk-login:unavailable') + }) + + it('rejects malformed codes', async () => { + await expect( + harness({ start: { status: 201, body: startBody({ userCode: 'abcd2345' }) } }).login.start() + ).rejects.toThrow('aiturk-login:invalid-response') + await expect( + harness({ start: { status: 201, body: startBody({ deviceCode: 'nope' }) } }).login.start() + ).rejects.toThrow('aiturk-login:invalid-response') + }) + + it('keeps a single login: a second start revokes the first code', async () => { + const h = harness() + await h.login.start() + await h.login.start() + + const urls = h.calls.map(c => c.url.split('/').pop()) + expect(urls).toEqual(['start', 'cancel', 'start']) + expect(h.calls[1].init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`) + }) + + it('cancels the code when the browser cannot be opened', async () => { + const h = harness({ openExternal: () => false }) + + await expect(h.login.start()).rejects.toThrow('aiturk-login:browser-failed') + expect(h.calls.at(-1)?.url).toMatch(/\/cancel$/) + }) +}) + +describe('aiturk login: poll', () => { + it('reports pending while the user has not approved', async () => { + const h = harness({ polls: [{ status: 200, body: { state: 'pending' } }] }) + await h.login.start() + + expect(await h.login.poll()).toEqual({ state: 'pending' }) + expect(h.calls[1].url).toBe('https://turkservis.online/v1/aiturk/device-auth/poll') + expect(h.calls[1].init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`) + expect(h.calls[1].init.redirect).toBe('error') + expect(h.backendRequest).not.toHaveBeenCalled() + expect(h.runCli).not.toHaveBeenCalled() + }) + + it('keeps waiting through a transient network error', async () => { + const h = harness({ polls: [new Error(`boom ${DEVICE_CODE}`), { status: 503 }] }) + await h.login.start() + + expect(await h.login.poll()).toEqual({ state: 'pending' }) + expect(await h.login.poll()).toEqual({ state: 'pending' }) + }) + + it('reports expired on 401 and stops asking the server', async () => { + const h = harness({ polls: [{ status: 401, body: { detail: 'expired' } }] }) + await h.login.start() + + expect(await h.login.poll()).toEqual({ state: 'expired' }) + const count = h.fetch.mock.calls.length + expect(await h.login.poll()).toEqual({ state: 'expired' }) + expect(h.fetch.mock.calls.length).toBe(count) + }) + + it('expires by the clock without a request', async () => { + const h = harness() + await h.login.start() + h.clock.now += 600_000 + + expect(await h.login.poll()).toEqual({ state: 'expired' }) + expect(h.calls.filter(c => c.url.endsWith('/poll'))).toHaveLength(0) + }) + + it('on approval saves the provider and pipes ONLY the CLI key to the CLI', async () => { + const h = harness({ polls: [{ status: 200, body: approved }] }) + await h.login.start({ profile: 'work', connectionId: 'conn-1' }) + + const result = await h.login.poll() + expect(result).toEqual({ state: 'connected', cli: 'ok' }) + expect(leaks(result)).toBe(false) + + expect(h.backendRequest).toHaveBeenCalledTimes(2) + expect(h.backendRequest.mock.calls[0][0]).toEqual({ + connectionId: 'conn-1', + path: '/api/providers/custom-endpoints/validate', + method: 'POST', + body: { + id: 'turkservis', + name: 'TurkServis · AITURK', + base_url: 'https://ai.turkservis.online/v1', + api_key: IDE_KEY, + model: '', + discover_models: true, + make_default: true + } + }) + expect(h.backendRequest.mock.calls[1][0]).toEqual({ + profile: 'work', + connectionId: 'conn-1', + path: '/api/providers/custom-endpoints', + method: 'POST', + body: { + id: 'turkservis', + name: 'TurkServis · AITURK', + base_url: 'https://ai.turkservis.online/v1', + api_key: IDE_KEY, + model: 'account/model-a', + models: ['account/model-a', 'account/model-b'], + discover_models: true, + make_default: true + } + }) + + expect(h.runCli).toHaveBeenCalledExactlyOnceWith(CLI_KEY, { keepOtherAccount: true }) + // No key ever rides a URL. + expect(h.calls.some(c => leaks(c.url))).toBe(false) + }) + + it('does not ask for the one-time keys again on later polls', async () => { + const h = harness({ polls: [{ status: 200, body: approved }] }) + await h.login.start() + + const first = await h.login.poll() + const fetches = h.fetch.mock.calls.length + const second = await h.login.poll() + + expect(second).toEqual(first) + expect(h.fetch.mock.calls.length).toBe(fetches) + expect(h.backendRequest).toHaveBeenCalledTimes(2) + expect(h.runCli).toHaveBeenCalledTimes(1) + }) + + it('shares one completion between overlapping polls', async () => { + const h = harness({ polls: [{ status: 200, body: approved }] }) + await h.login.start() + + const [a, b] = await Promise.all([h.login.poll(), h.login.poll()]) + expect(a).toEqual({ state: 'connected', cli: 'ok' }) + expect(b.state === 'connected' || b.state === 'pending').toBe(true) + expect(h.runCli).toHaveBeenCalledTimes(1) + }) + + it.each([ + [0, 'ok'], + [2, 'outdated'], + [3, 'other-account'], + [4, 'unknown-account'], + [1, 'failed'], + [null, 'failed'], + ['missing', 'missing'] + ] as const)('maps CLI exit %s to cli=%s', async (exit, cli) => { + const h = harness({ polls: [{ status: 200, body: approved }], cliExits: [exit] }) + await h.login.start() + + expect(await h.login.poll()).toEqual({ state: 'connected', cli }) + }) + + it('treats a throwing CLI runner as failed without leaking', async () => { + const h = harness({ polls: [{ status: 200, body: approved }] }) + h.runCli.mockRejectedValueOnce(new Error(`spawn failed ${CLI_KEY}`)) + await h.login.start() + + const result = await h.login.poll() + expect(result).toEqual({ state: 'connected', cli: 'failed' }) + }) + + it('does not save or touch the CLI when the key is rejected by the backend', async () => { + const h = harness({ + polls: [{ status: 200, body: approved }], + validate: { ok: false, reachable: true, models: [], message: `bad key ${IDE_KEY}` } + }) + + await h.login.start() + + const result = await h.login.poll() + expect(result).toEqual({ state: 'error', error: 'provider-failed' }) + expect(leaks(result)).toBe(false) + expect(h.backendRequest).toHaveBeenCalledTimes(1) + expect(h.runCli).not.toHaveBeenCalled() + }) + + it('does not invent a model when the catalog is empty', async () => { + const h = harness({ + polls: [{ status: 200, body: approved }], + validate: { ok: true, reachable: true, models: [], message: '' } + }) + + await h.login.start() + + expect(await h.login.poll()).toEqual({ state: 'error', error: 'no-models' }) + expect(h.backendRequest).toHaveBeenCalledTimes(1) + }) + + it('never surfaces backend error text (which could echo the key)', async () => { + const h = harness({ polls: [{ status: 200, body: approved }] }) + h.backendRequest.mockRejectedValueOnce(new Error(`500: echoed ${IDE_KEY}`)) + await h.login.start() + + const result = await h.login.poll() + expect(result).toEqual({ state: 'error', error: 'provider-failed' }) + expect(leaks(result)).toBe(false) + }) + + it('rejects an approval whose key is malformed', async () => { + const h = harness({ polls: [{ status: 200, body: { state: 'approved', key: 'not a key', cliKey: CLI_KEY } }] }) + await h.login.start() + + expect(await h.login.poll()).toEqual({ state: 'error', error: 'invalid-response' }) + expect(h.backendRequest).not.toHaveBeenCalled() + expect(h.runCli).not.toHaveBeenCalled() + }) +}) + +describe('aiturk login: other CLI account', () => { + async function otherAccount(overwriteExit: AiturkCliExit = 0) { + const h = harness({ polls: [{ status: 200, body: approved }], cliExits: [3, overwriteExit] }) + await h.login.start() + expect(await h.login.poll()).toEqual({ state: 'connected', cli: 'other-account' }) + + return h + } + + it('overwrites only after the user confirms, without the keep flag', async () => { + const h = await otherAccount() + + const result = await h.login.overwriteCli() + expect(result).toEqual({ cli: 'ok' }) + expect(leaks(result)).toBe(false) + expect(h.runCli).toHaveBeenLastCalledWith(CLI_KEY, { keepOtherAccount: false }) + expect(await h.login.poll()).toEqual({ state: 'connected', cli: 'ok' }) + + // Single use: the held key is gone. + await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite') + expect(h.runCli).toHaveBeenCalledTimes(2) + }) + + it('reports an overwrite that still fails', async () => { + const h = await otherAccount(4) + + expect(await h.login.overwriteCli()).toEqual({ cli: 'unknown-account' }) + }) + + it('drops the held CLI key after five minutes', async () => { + const h = await otherAccount() + const hold = h.timers.find(t => t.ms === AITURK_CLI_KEY_HOLD_MS) + expect(hold).toBeDefined() + + h.clock.now += AITURK_CLI_KEY_HOLD_MS + hold?.fn() + + await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite') + expect(h.runCli).toHaveBeenCalledTimes(1) + }) + + it('refuses a stale held key even if the timer never fired', async () => { + const h = await otherAccount() + h.clock.now += AITURK_CLI_KEY_HOLD_MS + + await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite') + expect(h.runCli).toHaveBeenCalledTimes(1) + }) + + it('drops the held CLI key on cancel', async () => { + const h = await otherAccount() + await h.login.cancel() + + const error = await h.login.overwriteCli().catch(e => e) + expect(error.message).toBe('aiturk-login:nothing-to-overwrite') + expect(leaks(error.message)).toBe(false) + expect(h.runCli).toHaveBeenCalledTimes(1) + }) + + it('has nothing to overwrite when the CLI simply succeeded', async () => { + const h = harness({ polls: [{ status: 200, body: approved }] }) + await h.login.start() + await h.login.poll() + + await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite') + }) +}) + +describe('aiturk login: cancel', () => { + it('revokes the code on the server and clears the session', async () => { + const h = harness() + await h.login.start() + + expect(await h.login.cancel()).toEqual({ ok: true }) + const last = h.calls.at(-1) + expect(last?.url).toBe('https://turkservis.online/v1/aiturk/device-auth/cancel') + expect(last?.init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`) + + const count = h.fetch.mock.calls.length + expect(await h.login.poll()).toEqual({ state: 'expired' }) + expect(h.fetch.mock.calls.length).toBe(count) + }) + + it('is a no-op without a login and survives a failing server', async () => { + const h = harness() + expect(await h.login.cancel()).toEqual({ ok: true }) + expect(h.fetch).not.toHaveBeenCalled() + + await h.login.start() + h.fetch.mockRejectedValueOnce(new Error('offline')) + expect(await h.login.cancel()).toEqual({ ok: true }) + }) +}) + +describe('aiturk CLI runner', () => { + const CLI_PATH = 'C:\\Users\\Test User\\AppData\\Local\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd' + const env = { LOCALAPPDATA: 'C:\\Users\\Test User\\AppData\\Local', SystemRoot: 'C:\\Windows' } + + function fakeSpawn(behaviour: (child: any) => void = child => child.emit('close', 0)) { + const state: any = { calls: [], stdin: [] as string[], killed: false } + + const spawn = vi.fn((command: string, args: string[], options: any) => { + const child: any = new EventEmitter() + child.stdin = new EventEmitter() + + child.stdin.end = (chunk: string) => { + state.stdin.push(chunk) + queueMicrotask(() => behaviour(child)) + } + + child.kill = () => { + state.killed = true + } + + state.calls.push({ command, args, options }) + + return child + }) + + return { spawn, state } + } + + it('finds the CLI only at the fixed install path', () => { + expect(resolveAiturkCliPath({ env, existsSync: p => p === CLI_PATH, platform: 'win32' })).toBe(CLI_PATH) + expect(resolveAiturkCliPath({ env, existsSync: () => false, platform: 'win32' })).toBeNull() + expect(resolveAiturkCliPath({ env: {}, existsSync: () => true, platform: 'win32' })).toBeNull() + expect(resolveAiturkCliPath({ env: { LOCALAPPDATA: 'relative' }, existsSync: () => true, platform: 'win32' })).toBeNull() + expect(resolveAiturkCliPath({ env, existsSync: () => true, platform: 'linux' })).toBeNull() + }) + + it('reports missing without spawning anything', async () => { + const { spawn } = fakeSpawn() + const run = createAiturkCliRunner({ spawn, existsSync: () => false, env, platform: 'win32' }) + + expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe('missing') + expect(spawn).not.toHaveBeenCalled() + }) + + it('sends the key on stdin only, through an absolute cmd.exe, without a shell', async () => { + const { spawn, state } = fakeSpawn() + const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' }) + + expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe(0) + + const { command, args, options } = state.calls[0] + expect(command).toBe('C:\\Windows\\System32\\cmd.exe') + expect(args).toEqual(['/d', '/s', '/c', `""${CLI_PATH}" login --key-stdin --keep-other-account"`]) + expect(options).toEqual({ + shell: false, + windowsHide: true, + windowsVerbatimArguments: true, + stdio: ['pipe', 'ignore', 'ignore'] + }) + expect(leaks([command, args, options])).toBe(false) + expect(state.stdin).toEqual([`${CLI_KEY}\n`]) + }) + + it('drops the keep flag for a confirmed overwrite', async () => { + const { spawn, state } = fakeSpawn() + const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' }) + + await run(CLI_KEY, { keepOtherAccount: false }) + expect(state.calls[0].args[3]).toBe(`""${CLI_PATH}" login --key-stdin"`) + }) + + it.each([0, 1, 2, 3, 4])('returns exit code %s', async code => { + const { spawn } = fakeSpawn(child => child.emit('close', code)) + const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' }) + + expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe(code) + }) + + it('returns null on spawn error, thrown spawn and stdin EPIPE', async () => { + const errored = fakeSpawn(child => child.emit('error', new Error('ENOENT'))) + expect( + await createAiturkCliRunner({ spawn: errored.spawn, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, { + keepOtherAccount: true + }) + ).toBeNull() + + const thrower = vi.fn(() => { + throw new Error('spawn EINVAL') + }) + + expect( + await createAiturkCliRunner({ spawn: thrower, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, { + keepOtherAccount: true + }) + ).toBeNull() + + const epipe = fakeSpawn(child => { + child.stdin.emit('error', new Error('EPIPE')) + child.emit('close', 2) + }) + + expect( + await createAiturkCliRunner({ spawn: epipe.spawn, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, { + keepOtherAccount: true + }) + ).toBe(2) + }) + + it('kills a CLI that hangs past the timeout', async () => { + const { spawn, state } = fakeSpawn(() => {}) + + let fire = () => {} + + const run = createAiturkCliRunner({ + spawn, + existsSync: () => true, + env, + platform: 'win32', + setTimer: (fn, ms) => { + expect(ms).toBe(60_000) + fire = fn + + return 1 + }, + clearTimer: () => {} + }) + + const pending = run(CLI_KEY, { keepOtherAccount: true }) + fire() + + expect(await pending).toBeNull() + expect(state.killed).toBe(true) + }) + + it('refuses an install path cmd.exe could reinterpret', async () => { + const { spawn } = fakeSpawn() + + const run = createAiturkCliRunner({ + spawn, + existsSync: () => true, + env: { LOCALAPPDATA: 'C:\\Users\\a%PATH%b\\AppData\\Local', SystemRoot: 'C:\\Windows' }, + platform: 'win32' + }) + + expect(await run(CLI_KEY, { keepOtherAccount: true })).toBeNull() + expect(spawn).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/electron/aiturk-login.ts b/apps/desktop/electron/aiturk-login.ts new file mode 100644 index 0000000..732f9ca --- /dev/null +++ b/apps/desktop/electron/aiturk-login.ts @@ -0,0 +1,653 @@ +/** + * aiturk-login.ts + * + * One-click TurkServis sign-in for AITURK IDE (device-code flow). + * + * The IDE shows a short code, the browser opens the turkservis.online approval + * page, and once the user approves, the server hands out two API keys exactly + * once: one for the IDE's TurkServis provider and one for the AITURK CLI. + * + * Security contract (do not relax): + * - Keys (`tsk_…`) and the device code (`tsdl_…`) never leave the main + * process: they are not returned to the renderer, not logged, not put into + * error messages, process arguments or URLs. + * - The CLI receives its key on stdin only, is launched from an absolute + * path without a shell, with a fixed argument list. + * - Network calls go only to https://turkservis.online, refuse redirects and + * time out after 20 s. + * + * All I/O is injected so the logic is unit-testable without Electron, a + * network or a real CLI (see aiturk-login.test.ts). + */ + +export const AITURK_LOGIN_ORIGIN = 'https://turkservis.online' +export const AITURK_LOGIN_FETCH_TIMEOUT_MS = 20_000 +export const AITURK_CLI_TIMEOUT_MS = 60_000 +export const AITURK_CLI_KEY_HOLD_MS = 5 * 60_000 + +const AITURK_PROVIDER_ID = 'turkservis' +const AITURK_PROVIDER_NAME = 'TurkServis · AITURK' +const AITURK_PROVIDER_BASE_URL = 'https://ai.turkservis.online/v1' + +const VERIFICATION_URI_RE = /^https:\/\/turkservis\.online\/account\/aiturk\/device-login\/[0-9a-f-]{36}$/ +const DEVICE_CODE_RE = /^tsdl_[A-Za-z0-9_-]{43}$/ +const USER_CODE_RE = /^[A-HJ-NP-Z2-9]{8}$/ +const API_KEY_RE = /^tsk_[A-Za-z0-9._-]{8,500}$/ +const MAX_EXPIRES_IN_S = 3600 + +export type AiturkCliResult = 'ok' | 'missing' | 'outdated' | 'other-account' | 'unknown-account' | 'failed' + +export type AiturkLoginErrorCode = + | 'busy' + | 'rate-limited' + | 'unavailable' + | 'invalid-response' + | 'browser-failed' + | 'provider-failed' + | 'no-models' + | 'nothing-to-overwrite' + +/** Error whose message is ONLY a fixed code — never upstream text, so a key + * echoed by a server or backend cannot ride an error into the renderer/log. */ +export class AiturkLoginError extends Error { + code: AiturkLoginErrorCode + + constructor(code: AiturkLoginErrorCode) { + super(`aiturk-login:${code}`) + this.name = 'AiturkLoginError' + this.code = code + } +} + +export interface AiturkLoginScope { + profile?: null | string + connectionId?: null | string +} + +export interface AiturkLoginStartResult { + userCode: string + verificationUri: string + expiresAt: number +} + +export type AiturkLoginPollResult = + | { state: 'pending' } + | { state: 'expired' } + | { state: 'connected'; cli: AiturkCliResult } + | { state: 'error'; error: AiturkLoginErrorCode } + +export interface AiturkFetchResponse { + status: number + json: () => Promise +} + +export type AiturkFetch = ( + url: string, + init: { method: string; headers: Record; body?: string; redirect: 'error'; signal?: AbortSignal } +) => Promise + +export interface AiturkBackendRequest { + path: string + method: string + body: unknown + profile?: string + connectionId?: string +} + +/** Outcome of one CLI launch: 'missing' when the CLI is not installed, the + * numeric exit code otherwise, null when it could not run / timed out. */ +export type AiturkCliExit = 'missing' | null | number + +export interface AiturkLoginDeps { + fetch: AiturkFetch + /** Open a URL in the system browser. `false` means it could not be opened. */ + openExternal: (url: string) => boolean | Promise | void + hostname: () => string + /** Same route the renderer's hermesApi uses (hermes:api handler internals). */ + backendRequest: (request: AiturkBackendRequest) => Promise + /** Run `aiturk login --key-stdin [--keep-other-account]`, key on stdin. */ + runCli: (cliKey: string, options: { keepOtherAccount: boolean }) => Promise + now: () => number + setTimer?: (fn: () => void, ms: number) => unknown + clearTimer?: (handle: unknown) => void + timeoutSignal?: (ms: number) => AbortSignal | undefined +} + +interface Session { + deviceCode: string + userCode: string + verificationUri: string + expiresAt: number + scope: { profile?: string; connectionId?: string } +} + +function deviceName(raw: unknown): string { + // Printable, 1–60 chars. A hostname is not a secret, but keep it tidy. + // eslint-disable-next-line no-control-regex + const cleaned = String(raw ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 60) + + return cleaned || 'AITURK IDE' +} + +function cleanScope(scope: unknown): { profile?: string; connectionId?: string } { + const source = (scope && typeof scope === 'object' ? scope : {}) as AiturkLoginScope + const profile = typeof source.profile === 'string' ? source.profile.trim() : '' + const connectionId = typeof source.connectionId === 'string' ? source.connectionId.trim() : '' + + return { + ...(profile ? { profile } : {}), + ...(connectionId ? { connectionId } : {}) + } +} + +function cliResultFromExit(exit: AiturkCliExit, keepOtherAccount: boolean): AiturkCliResult { + if (exit === 'missing') { + return 'missing' + } + + if (exit === 0) { + return 'ok' + } + + if (exit === 2) { + return 'outdated' + } + + if (exit === 3 && keepOtherAccount) { + return 'other-account' + } + + if (exit === 4) { + return 'unknown-account' + } + + return 'failed' +} + +export function createAiturkLogin(deps: AiturkLoginDeps) { + const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms)) + const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType)) + + const timeoutSignal = + deps.timeoutSignal ?? (ms => (typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(ms) : undefined)) + + let session: null | Session = null + // In-flight "approved → save provider → hand key to CLI" work. Concurrent + // polls share it so the one-time key is fetched and consumed exactly once. + let finishing: null | Promise = null + // Final outcome of the last login, replayed to later polls without a fetch. + let settled: null | AiturkLoginPollResult = null + // CLI key kept ONLY while the user decides whether to replace another + // account's CLI login. Main-process memory, dropped after 5 minutes. + let heldCli: null | { key: string; expiresAt: number; timer: unknown } = null + + function dropHeldCli() { + if (heldCli) { + clearTimer(heldCli.timer) + heldCli = null + } + } + + function holdCli(key: string) { + dropHeldCli() + + const timer = setTimer(() => { + heldCli = null + }, AITURK_CLI_KEY_HOLD_MS) + + // Never keep the app alive just for this timer. + ;(timer as { unref?: () => void } | null)?.unref?.() + heldCli = { key, expiresAt: deps.now() + AITURK_CLI_KEY_HOLD_MS, timer } + } + + async function post(path: string, bearer: null | string, body?: unknown): Promise { + return deps.fetch(`${AITURK_LOGIN_ORIGIN}${path}`, { + method: 'POST', + headers: { + Accept: 'application/json', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}) + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + redirect: 'error', + signal: timeoutSignal(AITURK_LOGIN_FETCH_TIMEOUT_MS) + }) + } + + async function cancelOnServer(deviceCode: string) { + try { + await post('/v1/aiturk/device-auth/cancel', deviceCode) + } catch { + // Best effort: the code expires on its own within 10 minutes. + } + } + + async function start(scope?: AiturkLoginScope): Promise { + if (finishing) { + throw new AiturkLoginError('busy') + } + + // One login at a time: a new start replaces (and revokes) the previous one. + const previous = session + session = null + settled = null + dropHeldCli() + + if (previous) { + await cancelOnServer(previous.deviceCode) + } + + let response: AiturkFetchResponse + + try { + response = await post('/v1/aiturk/device-auth/start', null, { + client: 'ide', + device_name: deviceName(deps.hostname()), + also_cli: true + }) + } catch { + throw new AiturkLoginError('unavailable') + } + + if (response.status === 429) { + throw new AiturkLoginError('rate-limited') + } + + if (response.status !== 201 && response.status !== 200) { + throw new AiturkLoginError('unavailable') + } + + let data: any + + try { + data = await response.json() + } catch { + throw new AiturkLoginError('invalid-response') + } + + const deviceCode = typeof data?.deviceCode === 'string' ? data.deviceCode : '' + const userCode = typeof data?.userCode === 'string' ? data.userCode : '' + const verificationUri = typeof data?.verificationUri === 'string' ? data.verificationUri : '' + const expiresIn = Number(data?.expiresIn) + const codeOk = DEVICE_CODE_RE.test(deviceCode) + + if ( + !codeOk || + !USER_CODE_RE.test(userCode) || + !VERIFICATION_URI_RE.test(verificationUri) || + !Number.isFinite(expiresIn) || + expiresIn <= 0 + ) { + // Never open a URL that is not exactly our approval page. + if (codeOk) { + await cancelOnServer(deviceCode) + } + + throw new AiturkLoginError('invalid-response') + } + + if (session || finishing) { + // A concurrent start won the race while we were waiting on the network. + await cancelOnServer(deviceCode) + + throw new AiturkLoginError('busy') + } + + const expiresAt = deps.now() + Math.min(expiresIn, MAX_EXPIRES_IN_S) * 1000 + session = { deviceCode, userCode, verificationUri, expiresAt, scope: cleanScope(scope) } + + let opened: unknown + + try { + opened = await deps.openExternal(verificationUri) + } catch { + opened = false + } + + if (opened === false) { + session = null + await cancelOnServer(deviceCode) + + throw new AiturkLoginError('browser-failed') + } + + return { userCode, verificationUri, expiresAt } + } + + async function saveProvider(apiKey: string, scope: Session['scope']) { + const endpoint = { + id: AITURK_PROVIDER_ID, + name: AITURK_PROVIDER_NAME, + base_url: AITURK_PROVIDER_BASE_URL, + api_key: apiKey, + discover_models: true, + make_default: true + } + + let checked: any + + try { + // Mirrors the renderer's validateCustomEndpoint (no profile scope) and + // saveCustomEndpoint (profile-scoped) calls. + checked = await deps.backendRequest({ + ...(scope.connectionId ? { connectionId: scope.connectionId } : {}), + path: '/api/providers/custom-endpoints/validate', + method: 'POST', + body: { ...endpoint, model: '' } + }) + } catch { + throw new AiturkLoginError('provider-failed') + } + + if (!checked?.ok) { + throw new AiturkLoginError('provider-failed') + } + + const models = Array.isArray(checked.models) ? checked.models.filter((m: unknown) => typeof m === 'string' && m) : [] + + if (!models.length) { + throw new AiturkLoginError('no-models') + } + + let saved: any + + try { + saved = await deps.backendRequest({ + ...scope, + path: '/api/providers/custom-endpoints', + method: 'POST', + body: { ...endpoint, model: models[0], models } + }) + } catch { + throw new AiturkLoginError('provider-failed') + } + + if (saved?.ok === false) { + throw new AiturkLoginError('provider-failed') + } + } + + async function handCliKey(cliKey: string, keepOtherAccount: boolean): Promise { + let exit: AiturkCliExit + + try { + exit = await deps.runCli(cliKey, { keepOtherAccount }) + } catch { + exit = null + } + + return cliResultFromExit(exit, keepOtherAccount) + } + + async function finish(key: string, cliKey: string, scope: Session['scope']): Promise { + try { + await saveProvider(key, scope) + } catch (error) { + return { state: 'error', error: error instanceof AiturkLoginError ? error.code : 'provider-failed' } + } + + let cli: AiturkCliResult = 'failed' + + if (cliKey) { + cli = await handCliKey(cliKey, true) + + if (cli === 'other-account') { + holdCli(cliKey) + } + } + + return { state: 'connected', cli } + } + + async function poll(): Promise { + if (finishing) { + return finishing + } + + if (settled) { + return settled + } + + const current = session + + if (!current) { + return { state: 'expired' } + } + + if (deps.now() >= current.expiresAt) { + session = null + + return { state: 'expired' } + } + + let response: AiturkFetchResponse + + try { + response = await post('/v1/aiturk/device-auth/poll', current.deviceCode) + } catch { + // Transient network trouble: keep waiting until the code expires. + return { state: 'pending' } + } + + if (session !== current) { + // Cancelled or replaced while the request was in flight. + return settled ?? (finishing ? finishing : { state: 'expired' }) + } + + if (response.status === 401) { + session = null + + return { state: 'expired' } + } + + if (response.status !== 200) { + return { state: 'pending' } + } + + let data: any + + try { + data = await response.json() + } catch { + return { state: 'pending' } + } + + if (session !== current) { + return settled ?? (finishing ? finishing : { state: 'expired' }) + } + + if (data?.state !== 'approved') { + return { state: 'pending' } + } + + // The server returns the keys exactly once; the device code is spent. + session = null + + const key = typeof data.key === 'string' && API_KEY_RE.test(data.key) ? data.key : '' + const cliKey = typeof data.cliKey === 'string' && API_KEY_RE.test(data.cliKey) ? data.cliKey : '' + + if (!key) { + settled = { state: 'error', error: 'invalid-response' } + + return settled + } + + finishing = finish(key, cliKey, current.scope) + .catch((): AiturkLoginPollResult => ({ state: 'error', error: 'provider-failed' })) + .then(result => { + settled = result + finishing = null + + return result + }) + + return finishing + } + + async function cancel(): Promise<{ ok: true }> { + const current = session + session = null + dropHeldCli() + + if (!finishing) { + settled = null + } + + if (current) { + await cancelOnServer(current.deviceCode) + } + + return { ok: true } + } + + /** User confirmed: replace the other account stored in the AITURK CLI. */ + async function overwriteCli(): Promise<{ cli: AiturkCliResult }> { + const held = heldCli + + if (!held || deps.now() >= held.expiresAt) { + dropHeldCli() + + throw new AiturkLoginError('nothing-to-overwrite') + } + + // Single use: drop it before launching so it cannot be replayed. + dropHeldCli() + + const cli = await handCliKey(held.key, false) + + if (settled?.state === 'connected') { + settled = { state: 'connected', cli } + } + + return { cli } + } + + return { start, poll, cancel, overwriteCli } +} + +// ── CLI runner ─────────────────────────────────────────────────────────────── + +export interface AiturkCliRunnerDeps { + /** child_process.spawn (injected for tests). */ + spawn: (command: string, args: string[], options: Record) => any + existsSync: (path: string) => boolean + env: Record + platform?: string + timeoutMs?: number + setTimer?: (fn: () => void, ms: number) => unknown + clearTimer?: (handle: unknown) => void +} + +// cmd.exe expands %VAR% and treats " specially even inside quotes; a path that +// carries either (or a control char) is refused rather than risk reparsing. +// eslint-disable-next-line no-control-regex +const UNSAFE_CMD_PATH_RE = /["%^!\u0000-\u001f]/ +const WINDOWS_ABSOLUTE_RE = /^[A-Za-z]:\\/ + +/** `%LOCALAPPDATA%\TurkServis\AITURK\program\bin\aiturk.cmd`, or null. */ +export function resolveAiturkCliPath(deps: Pick): null | string { + if ((deps.platform ?? process.platform) !== 'win32') { + return null + } + + const localAppData = String(deps.env.LOCALAPPDATA || '').replace(/[\\/]+$/, '') + + if (!WINDOWS_ABSOLUTE_RE.test(localAppData)) { + return null + } + + const candidate = `${localAppData}\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd` + + try { + return deps.existsSync(candidate) ? candidate : null + } catch { + return null + } +} + +/** + * Build the CLI runner used by createAiturkLogin. The key goes to the child's + * stdin and nowhere else: the argument list is a fixed array with no user + * input, there is no `shell: true`, and stdout/stderr are discarded. + */ +export function createAiturkCliRunner(deps: AiturkCliRunnerDeps) { + const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms)) + const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType)) + const timeoutMs = deps.timeoutMs ?? AITURK_CLI_TIMEOUT_MS + + return function runCli(cliKey: string, options: { keepOtherAccount: boolean }): Promise { + const cliPath = resolveAiturkCliPath(deps) + + if (!cliPath) { + return Promise.resolve('missing') + } + + const systemRoot = String(deps.env.SystemRoot || deps.env.SYSTEMROOT || 'C:\\Windows').replace(/[\\/]+$/, '') + + if (UNSAFE_CMD_PATH_RE.test(cliPath) || !WINDOWS_ABSOLUTE_RE.test(systemRoot) || UNSAFE_CMD_PATH_RE.test(systemRoot)) { + return Promise.resolve(null) + } + + const flags = options.keepOtherAccount ? 'login --key-stdin --keep-other-account' : 'login --key-stdin' + + // A .cmd cannot be spawned directly without a shell; run it through an + // absolute cmd.exe. With /s, cmd strips the outer quote pair and runs the + // rest verbatim, so the (validated, quoted) path survives spaces. + const args = ['/d', '/s', '/c', `""${cliPath}" ${flags}"`] + + return new Promise(resolve => { + let done = false + let timer: unknown = null + let child: any + + const settle = (value: AiturkCliExit) => { + if (done) { + return + } + + done = true + + if (timer !== null) { + clearTimer(timer) + } + + resolve(value) + } + + try { + child = deps.spawn(`${systemRoot}\\System32\\cmd.exe`, args, { + shell: false, + windowsHide: true, + windowsVerbatimArguments: true, + stdio: ['pipe', 'ignore', 'ignore'] + }) + } catch { + settle(null) + + return + } + + timer = setTimer(() => { + try { + child.kill() + } catch { + // Already gone. + } + + settle(null) + }, timeoutMs) + + child.once('error', () => settle(null)) + child.once('close', (code: null | number) => settle(typeof code === 'number' ? code : null)) + + try { + // A CLI that exits before reading stdin raises EPIPE here; ignore it, + // the exit code is the answer. + child.stdin.on('error', () => {}) + child.stdin.end(`${cliKey}\n`) + } catch { + // Same as above. + } + }) + } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index e238d13..340057a 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -33,6 +33,7 @@ import { } from 'electron' import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state' +import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login' import { describeVcRuntimeResult, ensureWindowsVcRuntime } from './windows-vc-runtime' import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport' import { appIconCandidates, resolveAppIcon } from './app-icon' @@ -17231,6 +17232,53 @@ ipcMain.handle('hermes:openExternal', (_event, url) => { } }) +// ── AITURK one-click sign-in (device code) ─────────────────────────────── +// The API keys returned by turkservis.online stay in THIS process: the +// provider is saved through the same backend route hermes:api uses and the +// CLI key is piped to `aiturk login --key-stdin`. The renderer only ever sees +// the short user code, the approval URL and a status. See aiturk-login.ts. +const aiturkLogin = createAiturkLogin({ + fetch: (url, init) => fetch(url, init), + openExternal: url => openExternalUrl(url), + hostname: () => os.hostname(), + backendRequest: request => handleHermesApiRequest(request), + runCli: createAiturkCliRunner({ spawn, existsSync: fs.existsSync, env: process.env }), + now: () => Date.now() +}) + +// Only the primary window's top-level frame may drive a sign-in. +function assertAiturkLoginSender(event) { + const contents = mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null + + if (!contents || event.sender !== contents || (event.senderFrame && event.senderFrame !== contents.mainFrame)) { + throw new Error('aiturk-login:unavailable') + } +} + +ipcMain.handle('aiturk:login:start', async (event, scope) => { + assertAiturkLoginSender(event) + + return aiturkLogin.start(scope) +}) + +ipcMain.handle('aiturk:login:poll', async event => { + assertAiturkLoginSender(event) + + return aiturkLogin.poll() +}) + +ipcMain.handle('aiturk:login:cancel', async event => { + assertAiturkLoginSender(event) + + return aiturkLogin.cancel() +}) + +ipcMain.handle('aiturk:login:overwrite-cli', async event => { + assertAiturkLoginSender(event) + + return aiturkLogin.overwriteCli() +}) + // ── Find-in-page (Ctrl/Cmd+F) ───────────────────────────────────────────── // The desktop supports multiple BrowserWindows (one primary plus any // per-session secondary windows spawned via `hermes:window:openSession`). diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 68d2d95..fef38d1 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -278,6 +278,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', { setDisableF12: blocked => ipcRenderer.send('hermes:devtools:disable-f12', blocked), setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)), openExternal: url => ipcRenderer.invoke('hermes:openExternal', url), + aiturkLogin: { + // One-click TurkServis sign-in. API keys never cross this bridge: main + // saves the provider and hands the CLI its key (electron/aiturk-login.ts). + start: scope => ipcRenderer.invoke('aiturk:login:start', scope), + poll: () => ipcRenderer.invoke('aiturk:login:poll'), + cancel: () => ipcRenderer.invoke('aiturk:login:cancel'), + overwriteCli: () => ipcRenderer.invoke('aiturk:login:overwrite-cli') + }, mcpOauth: { // One-shot loopback listener for MCP OAuth against remote backends: bind // on this machine, hand redirectUri to mcp.servers.oauth.start, then wait diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5984962..754a52a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "aiturk-ide", "productName": "AITURK IDE", "private": true, - "version": "1.0.0-beta.13", + "version": "1.0.0-beta.14", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "author": "AITURK / TurkServis", "repository": { diff --git a/apps/desktop/src/app/settings/custom-endpoints-settings.tsx b/apps/desktop/src/app/settings/custom-endpoints-settings.tsx index ecf613b..49ca6eb 100644 --- a/apps/desktop/src/app/settings/custom-endpoints-settings.tsx +++ b/apps/desktop/src/app/settings/custom-endpoints-settings.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' +import { AiturkLogin } from '@/components/aiturk-login' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' @@ -80,7 +81,10 @@ function toPayload(form: EndpointForm, models?: string[]): CustomEndpointUpdate export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: CustomEndpointsSettingsProps) { const { t } = useI18n() - const c = useAiturkCopy().custom + const aiturkCopy = useAiturkCopy() + const c = aiturkCopy.custom + const loginCopy = aiturkCopy.login + const loginLabel = loginCopy.settingsSignIn const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [testing, setTesting] = useState(false) @@ -129,6 +133,31 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C } }, []) + // One-click sign-in saved the TurkServis provider in the main process: + // reload the list and tell the app its default model changed. + async function handleAiturkConnected() { + try { + const data = await getCustomEndpoints() + setEndpoints(data.endpoints) + const current = data.endpoints.find(endpoint => endpoint.id === 'turkservis') + + if (current) { + setForm(formFromEndpoint(current)) + setDiscoveredModels(current.models) + + if (current.is_current) { + onMainModelChanged?.(current.id, current.model) + } + } + + triggerHaptic('success') + onConfigSaved?.() + notify({ kind: 'success', message: loginCopy.connected }) + } catch (err) { + notifyError(err, c.loadFailed) + } + } + async function handleSave() { try { setSaving(true) @@ -298,10 +327,13 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
- +
+ + +
diff --git a/apps/desktop/src/components/aiturk-login.tsx b/apps/desktop/src/components/aiturk-login.tsx new file mode 100644 index 0000000..76060f4 --- /dev/null +++ b/apps/desktop/src/components/aiturk-login.tsx @@ -0,0 +1,331 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import type { AiturkCliStatus } from '@/global' +import { useAiturkCopy } from '@/i18n/aiturk' +import { aiturkLoginScope } from '@/lib/aiturk-provider' +import { Loader2 } from '@/lib/icons' +import { notify } from '@/store/notifications' + +const POLL_INTERVAL_MS = 3000 + +type Phase = 'idle' | 'starting' | 'waiting' | 'question' | 'replacing' + +interface Waiting { + userCode: string + verificationUri: string +} + +type LoginCopy = ReturnType + +function errorCode(error: unknown): string { + // The main process only ever throws fixed codes ("aiturk-login:"). + return /aiturk-login:([a-z-]+)/.exec(error instanceof Error ? error.message : String(error))?.[1] ?? '' +} + +function errorText(code: string, copy: LoginCopy): string { + switch (code) { + case 'rate-limited': + return copy.login.rateLimited + + case 'browser-failed': + return copy.login.browserFailed + + case 'no-models': + return copy.noModels + + case 'provider-failed': + + case 'invalid-response': + return copy.failed + + default: + return copy.login.unavailable + } +} + +function cliText(cli: AiturkCliStatus, copy: LoginCopy): string { + switch (cli) { + case 'ok': + return copy.login.cliOk + + case 'outdated': + return copy.login.cliOutdated + + case 'failed': + + case 'unknown-account': + return copy.login.cliFailed + + default: + // 'missing' stays silent; 'other-account' is asked as a question. + return '' + } +} + +/** + * One-click TurkServis sign-in, shared by onboarding and Settings. + * + * The API keys never reach this component: the main process shows us only the + * short code, then reports `connected` once it has saved the provider and + * handed the AITURK CLI its own key. + */ +export function useAiturkLogin(onConnected: () => Promise | void) { + const copy = useAiturkCopy() + const [phase, setPhase] = useState('idle') + const [waiting, setWaiting] = useState(null) + const [error, setError] = useState('') + const [info, setInfo] = useState('') + const live = useRef(false) + const mounted = useRef(true) + const onConnectedRef = useRef(onConnected) + const copyRef = useRef(copy) + + onConnectedRef.current = onConnected + copyRef.current = copy + + // eslint-disable-next-line no-restricted-syntax -- mount flag + in-flight login flag, not an atom mirror + useEffect(() => { + mounted.current = true + + return () => { + mounted.current = false + + // Leaving mid-login: revoke the code and drop anything main still holds. + if (live.current) { + live.current = false + void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {}) + } + } + }, []) + + const finish = useCallback(async (cli: AiturkCliStatus) => { + live.current = false + const text = cliText(cli, copyRef.current) + + if (mounted.current) { + setWaiting(null) + setPhase('idle') + setInfo(text) + } + + try { + await onConnectedRef.current() + } catch { + // The provider is saved; a failed refresh is the caller's concern. + } + + // Onboarding closes this panel as soon as it is connected; keep the CLI + // note visible as a toast in that case. + if (!mounted.current && text) { + notify({ kind: cli === 'ok' ? 'success' : 'info', message: text }) + } + }, []) + + // eslint-disable-next-line no-restricted-syntax -- clears the in-flight login flag when polling ends + useEffect(() => { + if (phase !== 'waiting') { + return + } + + let stopped = false + let timer: ReturnType | undefined + + const tick = async () => { + const bridge = window.hermesDesktop?.aiturkLogin + + if (stopped || !bridge) { + return + } + + let result + + try { + result = await bridge.poll() + } catch { + result = { state: 'pending' as const } + } + + if (stopped) { + return + } + + if (result.state === 'pending') { + timer = setTimeout(() => void tick(), POLL_INTERVAL_MS) + + return + } + + if (result.state === 'connected') { + if (result.cli === 'other-account') { + setWaiting(null) + setPhase('question') + + return + } + + await finish(result.cli) + + return + } + + live.current = false + setWaiting(null) + setPhase('idle') + setError(result.state === 'expired' ? copyRef.current.login.expired : errorText(result.error, copyRef.current)) + } + + timer = setTimeout(() => void tick(), POLL_INTERVAL_MS) + + return () => { + stopped = true + clearTimeout(timer) + } + }, [phase, finish]) + + const start = useCallback(async () => { + const bridge = window.hermesDesktop?.aiturkLogin + + setError('') + setInfo('') + + if (!bridge) { + setError(copyRef.current.login.unsupported) + + return + } + + setPhase('starting') + + try { + const started = await bridge.start(aiturkLoginScope()) + + if (!mounted.current) { + void bridge.cancel().catch(() => {}) + + return + } + + live.current = true + setWaiting({ userCode: started.userCode, verificationUri: started.verificationUri }) + setPhase('waiting') + } catch (err) { + if (mounted.current) { + setPhase('idle') + setError(errorText(errorCode(err), copyRef.current)) + } + } + }, []) + + const cancel = useCallback(() => { + live.current = false + setWaiting(null) + setPhase('idle') + void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {}) + }, []) + + const reopen = useCallback(() => { + if (waiting) { + void window.hermesDesktop?.openExternal(waiting.verificationUri).catch(() => {}) + } + }, [waiting]) + + const replaceCli = useCallback(async () => { + setPhase('replacing') + + let cli: AiturkCliStatus = 'failed' + + try { + cli = (await window.hermesDesktop?.aiturkLogin?.overwriteCli())?.cli ?? 'failed' + } catch { + cli = 'failed' + } + + await finish(cli === 'other-account' ? 'failed' : cli) + }, [finish]) + + const keepCli = useCallback(async () => { + void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {}) + await finish('missing') + }, [finish]) + + return { phase, waiting, error, info, start, cancel, reopen, replaceCli, keepCli } +} + +interface AiturkLoginProps { + /** Runs once the TurkServis provider is saved (refresh lists, advance onboarding…). */ + onConnected: () => Promise | void + /** Idle button label; defaults to "Giriş yap". */ + label?: string + variant?: 'default' | 'outline' + disabled?: boolean +} + +export function AiturkLogin({ onConnected, label, variant = 'default', disabled }: AiturkLoginProps) { + const copy = useAiturkCopy() + const login = useAiturkLogin(onConnected) + + if (login.phase === 'waiting' && login.waiting) { + return ( +
+
+
{copy.login.codeLabel}
+ + {login.waiting.userCode} + +
+

{copy.login.confirmHint}

+
+ + +
+
+ ) + } + + if (login.phase === 'question' || login.phase === 'replacing') { + const replacing = login.phase === 'replacing' + + return ( +
+

{copy.login.cliOtherAccount}

+
+ + +
+
+ ) + } + + const starting = login.phase === 'starting' + + return ( +
+
+ +
+ {login.error ? ( +

+ {login.error} +

+ ) : null} + {login.info ? ( +

+ {login.info} +

+ ) : null} +
+ ) +} diff --git a/apps/desktop/src/components/onboarding/aiturk-provider.test.tsx b/apps/desktop/src/components/onboarding/aiturk-provider.test.tsx index 7e07537..0aa022d 100644 --- a/apps/desktop/src/components/onboarding/aiturk-provider.test.tsx +++ b/apps/desktop/src/components/onboarding/aiturk-provider.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes' @@ -9,7 +9,12 @@ import { AiturkProvider } from './aiturk-provider' vi.mock('@/hermes', () => ({ saveCustomEndpoint: vi.fn(), validateCustomEndpoint: vi.fn() })) vi.mock('./flow', () => ({ DocsLink: ({ children, href }: { children: React.ReactNode; href: string }) => {children} })) -afterEach(() => { cleanup(); vi.resetAllMocks() }) +afterEach(() => { + cleanup() + vi.resetAllMocks() + vi.useRealTimers() + delete (window as { hermesDesktop?: unknown }).hermesDesktop +}) function setup() { const onConnected = vi.fn().mockResolvedValue(undefined) @@ -17,6 +22,7 @@ function setup() { const input = screen.getByLabelText('TurkServis API anahtarı') as HTMLInputElement fireEvent.change(input, { target: { value: 'test-key-never-real' } }) fireEvent.click(screen.getByRole('button', { name: 'Bağlan' })) + return { input, onConnected } } @@ -47,3 +53,163 @@ describe('TurkServis provider setup', () => { expect(saveCustomEndpoint).not.toHaveBeenCalled() }) }) + +const URI = 'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e' + +function setupLogin(polls: unknown[]) { + vi.useFakeTimers() + + const bridge = { + start: vi.fn().mockResolvedValue({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: Date.now() + 600_000 }), + poll: vi.fn(async () => polls.shift() ?? { state: 'pending' }), + cancel: vi.fn().mockResolvedValue({ ok: true }), + overwriteCli: vi.fn().mockResolvedValue({ cli: 'ok' }) + } + + const openExternal = vi.fn().mockResolvedValue(undefined) + + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { aiturkLogin: bridge, openExternal } + const onConnected = vi.fn().mockResolvedValue(undefined) + render() + + return { bridge, onConnected, openExternal } +} + +const click = async (name: string) => { + await act(async () => { + fireEvent.click(screen.getByRole('button', { name })) + await Promise.resolve() + }) +} + +const tick = () => + act(async () => { + await vi.advanceTimersByTimeAsync(3000) + }) + +describe('TurkServis one-click sign-in', () => { + it('shows the code, lets the user reopen the page and polls every 3 seconds', async () => { + const { bridge, onConnected, openExternal } = setupLogin([{ state: 'pending' }, { state: 'pending' }]) + await click('Giriş yap') + + expect(bridge.start).toHaveBeenCalledOnce() + expect(screen.getByLabelText('Giriş kodu').textContent).toBe('ABCD2345') + expect(screen.getByText('Tarayıcıda açılan sayfadaki kod bununla aynıysa onaylayın.')).toBeTruthy() + expect(bridge.poll).not.toHaveBeenCalled() + + await tick() + expect(bridge.poll).toHaveBeenCalledTimes(1) + await tick() + expect(bridge.poll).toHaveBeenCalledTimes(2) + + await click('Sayfayı yeniden aç') + expect(openExternal).toHaveBeenCalledWith(URI) + expect(onConnected).not.toHaveBeenCalled() + // No key ever reaches the renderer through this flow. + expect(saveCustomEndpoint).not.toHaveBeenCalled() + expect(validateCustomEndpoint).not.toHaveBeenCalled() + }) + + it('calls onConnected once the main process reports the provider connected', async () => { + const { bridge, onConnected } = setupLogin([{ state: 'pending' }, { state: 'connected', cli: 'ok' }]) + await click('Giriş yap') + await tick() + expect(onConnected).not.toHaveBeenCalled() + await tick() + + expect(onConnected).toHaveBeenCalledOnce() + expect(screen.getByRole('status').textContent).toBe('AITURK CLI de bu hesapla hazır.') + expect(screen.queryByLabelText('Giriş kodu')).toBeNull() + await tick() + expect(bridge.poll).toHaveBeenCalledTimes(2) + }) + + it.each([ + ['missing', ''], + ['outdated', 'AITURK CLI güncellenince kendiliğinden bağlanması için yeniden giriş yapın.'], + ['failed', 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.'], + ['unknown-account', 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.'] + ])('connects and explains CLI result %s', async (cli, text) => { + const { onConnected } = setupLogin([{ state: 'connected', cli }]) + await click('Giriş yap') + await tick() + + expect(onConnected).toHaveBeenCalledOnce() + expect(screen.queryByRole('status')?.textContent ?? '').toBe(text) + }) + + it('asks before replacing another CLI account, then replaces on confirm', async () => { + const { bridge, onConnected } = setupLogin([{ state: 'connected', cli: 'other-account' }]) + await click('Giriş yap') + await tick() + + expect(screen.getByText('AITURK CLI’de başka bir hesap kayıtlı. Bu hesapla değiştirilsin mi?')).toBeTruthy() + expect(onConnected).not.toHaveBeenCalled() + expect(bridge.overwriteCli).not.toHaveBeenCalled() + + await click('Değiştir') + expect(bridge.overwriteCli).toHaveBeenCalledOnce() + expect(onConnected).toHaveBeenCalledOnce() + expect(screen.getByRole('status').textContent).toBe('AITURK CLI de bu hesapla hazır.') + }) + + it('leaves the other CLI account alone when the user says so', async () => { + const { bridge, onConnected } = setupLogin([{ state: 'connected', cli: 'other-account' }]) + await click('Giriş yap') + await tick() + await click('Olduğu gibi bırak') + + expect(bridge.overwriteCli).not.toHaveBeenCalled() + expect(bridge.cancel).toHaveBeenCalledOnce() + expect(onConnected).toHaveBeenCalledOnce() + }) + + it('cancels on Vazgeç and returns to the sign-in button', async () => { + const { bridge, onConnected } = setupLogin([]) + await click('Giriş yap') + await click('Vazgeç') + + expect(bridge.cancel).toHaveBeenCalledOnce() + expect(screen.getByRole('button', { name: 'Giriş yap' })).toBeTruthy() + await tick() + expect(bridge.poll).not.toHaveBeenCalled() + expect(onConnected).not.toHaveBeenCalled() + }) + + it('reports an expired code and a failed start without connecting', async () => { + const { bridge, onConnected } = setupLogin([{ state: 'expired' }]) + await click('Giriş yap') + await tick() + expect(screen.getByRole('alert').textContent).toBe('Giriş kodunun süresi doldu. Yeniden deneyin.') + + bridge.start.mockRejectedValueOnce( + new Error("Error invoking remote method 'aiturk:login:start': AiturkLoginError: aiturk-login:rate-limited") + ) + await click('Giriş yap') + expect(screen.getByRole('alert').textContent).toBe('Çok fazla deneme yapıldı. Biraz sonra yeniden deneyin.') + expect(onConnected).not.toHaveBeenCalled() + }) + + it('revokes a pending login when the panel goes away', async () => { + const { bridge } = setupLogin([]) + await click('Giriş yap') + cleanup() + + expect(bridge.cancel).toHaveBeenCalledOnce() + }) + + it('still connects with a pasted API key', async () => { + vi.mocked(validateCustomEndpoint).mockResolvedValue({ ok: true, reachable: true, models: ['account/model-a'], message: '' }) + vi.mocked(saveCustomEndpoint).mockResolvedValue({ id: 'turkservis', endpoints: [], current: { base_url: 'https://ai.turkservis.online/v1', model: 'account/model-a', provider: 'turkservis' }, ok: true }) + const { bridge, onConnected } = setupLogin([]) + vi.useRealTimers() + + expect(screen.getByText('ya da API anahtarı yapıştırın')).toBeTruthy() + fireEvent.change(screen.getByLabelText('TurkServis API anahtarı'), { target: { value: 'test-key-never-real' } }) + fireEvent.click(screen.getByRole('button', { name: 'Bağlan' })) + + await waitFor(() => expect(onConnected).toHaveBeenCalledOnce()) + expect(saveCustomEndpoint).toHaveBeenCalledWith(expect.objectContaining({ api_key: 'test-key-never-real', model: 'account/model-a' })) + expect(bridge.start).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/components/onboarding/aiturk-provider.tsx b/apps/desktop/src/components/onboarding/aiturk-provider.tsx index 6d50164..149888d 100644 --- a/apps/desktop/src/components/onboarding/aiturk-provider.tsx +++ b/apps/desktop/src/components/onboarding/aiturk-provider.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' +import { AiturkLogin } from '@/components/aiturk-login' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes' @@ -18,18 +19,22 @@ export function AiturkProvider({ onConnected }: { onConnected: () => Promise PromiseTurkServis · AITURK

{copy.pitch}

+ +
{copy.login.orPasteKey}
{error ?

{error}

: null}
{copy.getKey} -
diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index aff6423..1bd0c15 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -323,6 +323,14 @@ declare global { setDisableF12?: (blocked: boolean) => void setPreviewShortcutActive?: (active: boolean) => void openExternal: (url: string) => Promise + /** One-click TurkServis sign-in (electron/aiturk-login.ts). API keys stay + * in the main process; only the short code and a status come back. */ + aiturkLogin?: { + start: (scope?: { connectionId?: string; profile?: string }) => Promise + poll: () => Promise + cancel: () => Promise<{ ok: true }> + overwriteCli: () => Promise<{ cli: AiturkCliStatus }> + } /** One-shot loopback callback listener for MCP OAuth against remote * backends (electron/mcp-oauth-callback-ipc.ts): bind on THIS machine, * pass redirectUri as client_redirect_uri to mcp.servers.oauth.start, @@ -1212,6 +1220,20 @@ export type DesktopBootstrapEvent = docsUrl: string } +export type AiturkCliStatus = 'ok' | 'missing' | 'outdated' | 'other-account' | 'unknown-account' | 'failed' + +export interface AiturkLoginStart { + userCode: string + verificationUri: string + expiresAt: number +} + +export type AiturkLoginPoll = + | { state: 'pending' } + | { state: 'expired' } + | { state: 'connected'; cli: AiturkCliStatus } + | { state: 'error'; error: string } + export interface HermesApiRequest { path: string method?: string diff --git a/apps/desktop/src/i18n/aiturk.ts b/apps/desktop/src/i18n/aiturk.ts index aa40254..80fb86c 100644 --- a/apps/desktop/src/i18n/aiturk.ts +++ b/apps/desktop/src/i18n/aiturk.ts @@ -30,6 +30,28 @@ const en = { getKey: 'My account and API keys', noModels: 'No models are available for this key.', failed: 'Could not connect to TurkServis.', + login: { + signIn: 'Sign in', + settingsSignIn: 'Sign in to AITURK', + starting: 'Starting sign-in…', + codeLabel: 'Sign-in code', + confirmHint: 'Approve if the code on the page opened in your browser matches this one.', + reopen: 'Reopen the page', + cancel: 'Cancel', + orPasteKey: 'or paste an API key', + connected: 'Your TurkServis account is connected.', + expired: 'The sign-in code expired. Try again.', + rateLimited: 'Too many attempts. Try again in a little while.', + unavailable: 'Could not start sign-in. Check your internet connection and try again.', + browserFailed: 'Could not open the browser. Try again.', + unsupported: 'One-click sign-in is not available here. Paste an API key instead.', + cliOk: 'AITURK CLI is ready with this account too.', + cliOutdated: 'After AITURK CLI is updated, sign in again to connect it automatically.', + cliOtherAccount: 'AITURK CLI has a different account saved. Replace it with this account?', + cliReplace: 'Replace', + cliKeep: 'Leave as is', + cliFailed: 'AITURK CLI could not be connected; run `aiturk login` in a terminal.' + }, custom: { title: 'Custom endpoints', active: 'Active', @@ -79,6 +101,28 @@ const tr: typeof en = { getKey: 'Hesabım ve API anahtarlarım', noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.', failed: 'TurkServis bağlantısı kurulamadı.', + login: { + signIn: 'Giriş yap', + settingsSignIn: 'AITURK’e giriş yap', + starting: 'Giriş başlatılıyor…', + codeLabel: 'Giriş kodu', + confirmHint: 'Tarayıcıda açılan sayfadaki kod bununla aynıysa onaylayın.', + reopen: 'Sayfayı yeniden aç', + cancel: 'Vazgeç', + orPasteKey: 'ya da API anahtarı yapıştırın', + connected: 'TurkServis hesabınız bağlandı.', + expired: 'Giriş kodunun süresi doldu. Yeniden deneyin.', + rateLimited: 'Çok fazla deneme yapıldı. Biraz sonra yeniden deneyin.', + unavailable: 'Giriş başlatılamadı. İnternet bağlantınızı kontrol edip yeniden deneyin.', + browserFailed: 'Tarayıcı açılamadı. Yeniden deneyin.', + unsupported: 'Tek tıkla giriş burada kullanılamıyor. API anahtarı yapıştırın.', + cliOk: 'AITURK CLI de bu hesapla hazır.', + cliOutdated: 'AITURK CLI güncellenince kendiliğinden bağlanması için yeniden giriş yapın.', + cliOtherAccount: 'AITURK CLI’de başka bir hesap kayıtlı. Bu hesapla değiştirilsin mi?', + cliReplace: 'Değiştir', + cliKeep: 'Olduğu gibi bırak', + cliFailed: 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.' + }, custom: { title: 'Özel API bağlantıları', active: 'Etkin', diff --git a/apps/desktop/src/lib/aiturk-provider.ts b/apps/desktop/src/lib/aiturk-provider.ts index 2f998e0..f8b928b 100644 --- a/apps/desktop/src/lib/aiturk-provider.ts +++ b/apps/desktop/src/lib/aiturk-provider.ts @@ -1,8 +1,15 @@ +import { connectionScoped, profileScoped } from '@/api/client' import type { CustomEndpointUpdate } from '@/types/hermes' export const AITURK_API_BASE_URL = 'https://ai.turkservis.online/v1' export const AITURK_ACCOUNT_URL = 'https://turkservis.online/panel' +/** Where the main process should save the provider after a one-click sign-in: + * the same (connection, profile) the renderer's saveCustomEndpoint targets. */ +export function aiturkLoginScope(): { connectionId?: string; profile?: string } { + return { ...connectionScoped(), ...profileScoped() } +} + export function aiturkEndpoint(apiKey: string, model = '', models?: string[]): CustomEndpointUpdate { return { id: 'turkservis', diff --git a/package-lock.json b/package-lock.json index 9f231a4..712ceb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ }, "apps/desktop": { "name": "aiturk-ide", - "version": "1.0.0-beta.13", + "version": "1.0.0-beta.14", "license": "MIT", "dependencies": { "@assistant-ui/core": "0.2.23",