/** * 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 /** Wait before the single second try of `device-auth/start` after a thrown network error. */ export const AITURK_LOGIN_START_RETRY_DELAY_MS = 500 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 | undefined // A long-idle process can fail its first request on a dead pooled // connection. Only a THROWN network error (no HTTP answer, so no code was // handed to us) is tried once more; an HTTP answer is never retried, so // one start cannot knowingly leave two codes behind. A timeout already // used its whole budget and is not retried. for (let attempt = 1; response === undefined; attempt++) { try { response = await post('/v1/aiturk/device-auth/start', null, { client: 'ide', device_name: deviceName(deps.hostname()), also_cli: true }) } catch (error) { const name = (error as null | { name?: unknown })?.name if (attempt >= 2 || name === 'TimeoutError' || name === 'AbortError') { throw new AiturkLoginError('unavailable') } await new Promise(resolve => void setTimer(resolve, AITURK_LOGIN_START_RETRY_DELAY_MS)) } } 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. } }) } }