Files
aiturk-hermes-ide/apps/desktop/electron/aiturk-login.ts
T
yilsemandClaude Opus 5.5 25d8c18c10 feat(aiturk): one-click sign-in for AITURK IDE (1.0.0-beta.14)
The onboarding and provider settings get a Sign in button: the IDE shows a
short code, opens the turkservis.online approval page, and after approval
saves the TurkServis provider itself and hands a separate member key to the
AITURK CLI over stdin (aiturk login --key-stdin --keep-other-account).
Keys never reach the renderer, logs, process arguments or URLs. Pasting an
API key remains as the fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-03 15:13:23 +03:00

654 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<unknown>
}
export type AiturkFetch = (
url: string,
init: { method: string; headers: Record<string, string>; body?: string; redirect: 'error'; signal?: AbortSignal }
) => Promise<AiturkFetchResponse>
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<boolean | void> | void
hostname: () => string
/** Same route the renderer's hermesApi uses (hermes:api handler internals). */
backendRequest: (request: AiturkBackendRequest) => Promise<unknown>
/** Run `aiturk login --key-stdin [--keep-other-account]`, key on stdin. */
runCli: (cliKey: string, options: { keepOtherAccount: boolean }) => Promise<AiturkCliExit>
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<typeof setTimeout>))
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<AiturkLoginPollResult> = 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<AiturkFetchResponse> {
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<AiturkLoginStartResult> {
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<AiturkCliResult> {
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<AiturkLoginPollResult> {
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<AiturkLoginPollResult> {
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<string, unknown>) => any
existsSync: (path: string) => boolean
env: Record<string, string | undefined>
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<AiturkCliRunnerDeps, 'env' | 'existsSync' | 'platform'>): 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<typeof setTimeout>))
const timeoutMs = deps.timeoutMs ?? AITURK_CLI_TIMEOUT_MS
return function runCli(cliKey: string, options: { keepOtherAccount: boolean }): Promise<AiturkCliExit> {
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.
}
})
}
}