Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d813c90ae | ||
|
|
456919df43 | ||
|
|
52f792f00f |
No files matched your search
@@ -1,605 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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<string, unknown> = {}) => ({
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,653 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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.
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
} from 'electron'
|
} from 'electron'
|
||||||
|
|
||||||
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
|
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
|
||||||
import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login'
|
|
||||||
import { describeVcRuntimeResult, ensureWindowsVcRuntime } from './windows-vc-runtime'
|
import { describeVcRuntimeResult, ensureWindowsVcRuntime } from './windows-vc-runtime'
|
||||||
import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport'
|
import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport'
|
||||||
import { appIconCandidates, resolveAppIcon } from './app-icon'
|
import { appIconCandidates, resolveAppIcon } from './app-icon'
|
||||||
@@ -17232,53 +17231,6 @@ 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) ─────────────────────────────────────────────
|
// ── Find-in-page (Ctrl/Cmd+F) ─────────────────────────────────────────────
|
||||||
// The desktop supports multiple BrowserWindows (one primary plus any
|
// The desktop supports multiple BrowserWindows (one primary plus any
|
||||||
// per-session secondary windows spawned via `hermes:window:openSession`).
|
// per-session secondary windows spawned via `hermes:window:openSession`).
|
||||||
|
|||||||
@@ -278,14 +278,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||||||
setDisableF12: blocked => ipcRenderer.send('hermes:devtools:disable-f12', blocked),
|
setDisableF12: blocked => ipcRenderer.send('hermes:devtools:disable-f12', blocked),
|
||||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
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: {
|
mcpOauth: {
|
||||||
// One-shot loopback listener for MCP OAuth against remote backends: bind
|
// One-shot loopback listener for MCP OAuth against remote backends: bind
|
||||||
// on this machine, hand redirectUri to mcp.servers.oauth.start, then wait
|
// on this machine, hand redirectUri to mcp.servers.oauth.start, then wait
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "aiturk-ide",
|
"name": "aiturk-ide",
|
||||||
"productName": "AITURK IDE",
|
"productName": "AITURK IDE",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0-beta.14",
|
"version": "1.0.0-beta.12",
|
||||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||||
"author": "AITURK / TurkServis",
|
"author": "AITURK / TurkServis",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { AiturkLogin } from '@/components/aiturk-login'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -81,10 +80,7 @@ function toPayload(form: EndpointForm, models?: string[]): CustomEndpointUpdate
|
|||||||
|
|
||||||
export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: CustomEndpointsSettingsProps) {
|
export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: CustomEndpointsSettingsProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const aiturkCopy = useAiturkCopy()
|
const c = useAiturkCopy().custom
|
||||||
const c = aiturkCopy.custom
|
|
||||||
const loginCopy = aiturkCopy.login
|
|
||||||
const loginLabel = loginCopy.settingsSignIn
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
@@ -133,31 +129,6 @@ 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() {
|
async function handleSave() {
|
||||||
try {
|
try {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -327,13 +298,10 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-3 flex flex-wrap items-start gap-2">
|
<Button className="mb-3" onClick={() => {
|
||||||
<AiturkLogin label={loginLabel} onConnected={handleAiturkConnected} />
|
setForm({ ...EMPTY_FORM, name: 'TurkServis · AITURK', baseUrl: AITURK_API_BASE_URL })
|
||||||
<Button onClick={() => {
|
setDiscoveredModels([])
|
||||||
setForm({ ...EMPTY_FORM, name: 'TurkServis · AITURK', baseUrl: AITURK_API_BASE_URL })
|
}} variant="outline">{c.preset}</Button>
|
||||||
setDiscoveredModels([])
|
|
||||||
}} variant="outline">{c.preset}</Button>
|
|
||||||
</div>
|
|
||||||
<SectionHeading icon={Plus} title={form.id ? c.edit : c.add} />
|
<SectionHeading icon={Plus} title={form.id ? c.edit : c.add} />
|
||||||
<div className="grid gap-3 rounded-md border border-border/50 p-3">
|
<div className="grid gap-3 rounded-md border border-border/50 p-3">
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
|||||||
@@ -1,331 +0,0 @@
|
|||||||
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<typeof useAiturkCopy>
|
|
||||||
|
|
||||||
function errorCode(error: unknown): string {
|
|
||||||
// The main process only ever throws fixed codes ("aiturk-login:<code>").
|
|
||||||
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> | void) {
|
|
||||||
const copy = useAiturkCopy()
|
|
||||||
const [phase, setPhase] = useState<Phase>('idle')
|
|
||||||
const [waiting, setWaiting] = useState<null | Waiting>(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<typeof setTimeout> | 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> | 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 (
|
|
||||||
<div className="grid gap-3" data-testid="aiturk-login-code">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">{copy.login.codeLabel}</div>
|
|
||||||
<output aria-label={copy.login.codeLabel} className="block font-mono text-3xl font-semibold tracking-[0.3em]">
|
|
||||||
{login.waiting.userCode}
|
|
||||||
</output>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">{copy.login.confirmHint}</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button onClick={login.reopen} size="sm" variant="outline">
|
|
||||||
{copy.login.reopen}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={login.cancel} size="sm" variant="ghost">
|
|
||||||
{copy.login.cancel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (login.phase === 'question' || login.phase === 'replacing') {
|
|
||||||
const replacing = login.phase === 'replacing'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-3" role="group">
|
|
||||||
<p className="text-sm">{copy.login.cliOtherAccount}</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button disabled={replacing} onClick={() => void login.replaceCli()} size="sm">
|
|
||||||
{replacing ? <Loader2 className="animate-spin" /> : null}
|
|
||||||
{copy.login.cliReplace}
|
|
||||||
</Button>
|
|
||||||
<Button disabled={replacing} onClick={() => void login.keepCli()} size="sm" variant="outline">
|
|
||||||
{copy.login.cliKeep}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const starting = login.phase === 'starting'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<div>
|
|
||||||
<Button disabled={disabled || starting} onClick={() => void login.start()} variant={variant}>
|
|
||||||
{starting ? <Loader2 className="animate-spin" /> : null}
|
|
||||||
{starting ? copy.login.starting : (label ?? copy.login.signIn)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{login.error ? (
|
|
||||||
<p className="text-sm text-destructive" role="alert">
|
|
||||||
{login.error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{login.info ? (
|
|
||||||
<p className="text-sm text-muted-foreground" role="status">
|
|
||||||
{login.info}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
||||||
@@ -9,12 +9,7 @@ import { AiturkProvider } from './aiturk-provider'
|
|||||||
vi.mock('@/hermes', () => ({ saveCustomEndpoint: vi.fn(), validateCustomEndpoint: vi.fn() }))
|
vi.mock('@/hermes', () => ({ saveCustomEndpoint: vi.fn(), validateCustomEndpoint: vi.fn() }))
|
||||||
vi.mock('./flow', () => ({ DocsLink: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a> }))
|
vi.mock('./flow', () => ({ DocsLink: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a> }))
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => { cleanup(); vi.resetAllMocks() })
|
||||||
cleanup()
|
|
||||||
vi.resetAllMocks()
|
|
||||||
vi.useRealTimers()
|
|
||||||
delete (window as { hermesDesktop?: unknown }).hermesDesktop
|
|
||||||
})
|
|
||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
const onConnected = vi.fn().mockResolvedValue(undefined)
|
const onConnected = vi.fn().mockResolvedValue(undefined)
|
||||||
@@ -22,7 +17,6 @@ function setup() {
|
|||||||
const input = screen.getByLabelText('TurkServis API anahtarı') as HTMLInputElement
|
const input = screen.getByLabelText('TurkServis API anahtarı') as HTMLInputElement
|
||||||
fireEvent.change(input, { target: { value: 'test-key-never-real' } })
|
fireEvent.change(input, { target: { value: 'test-key-never-real' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Bağlan' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Bağlan' }))
|
||||||
|
|
||||||
return { input, onConnected }
|
return { input, onConnected }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,163 +47,3 @@ describe('TurkServis provider setup', () => {
|
|||||||
expect(saveCustomEndpoint).not.toHaveBeenCalled()
|
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(<I18nProvider configClient={null}><AiturkProvider onConnected={onConnected} /></I18nProvider>)
|
|
||||||
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { AiturkLogin } from '@/components/aiturk-login'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
||||||
@@ -19,22 +18,18 @@ export function AiturkProvider({ onConnected }: { onConnected: () => Promise<voi
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
async function connect() {
|
async function connect() {
|
||||||
if (busy || !key.trim()) {return}
|
if (busy || !key.trim()) return
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Discovery goes through the connected backend's existing credential
|
// Discovery goes through the connected backend's existing credential
|
||||||
// boundary. Do not ship a shared key or hard-code a model catalog.
|
// boundary. Do not ship a shared key or hard-code a model catalog.
|
||||||
const checked = await validateCustomEndpoint(aiturkEndpoint(key))
|
const checked = await validateCustomEndpoint(aiturkEndpoint(key))
|
||||||
|
if (!checked.ok) throw new Error(checked.message || copy.failed)
|
||||||
if (!checked.ok) {throw new Error(checked.message || copy.failed)}
|
|
||||||
const model = checked.models[0]
|
const model = checked.models[0]
|
||||||
|
if (!model) throw new Error(copy.noModels)
|
||||||
if (!model) {throw new Error(copy.noModels)}
|
|
||||||
const saved = await saveCustomEndpoint(aiturkEndpoint(key, model, checked.models))
|
const saved = await saveCustomEndpoint(aiturkEndpoint(key, model, checked.models))
|
||||||
|
if (saved.ok === false) throw new Error(copy.failed)
|
||||||
if (saved.ok === false) {throw new Error(copy.failed)}
|
|
||||||
setKey('')
|
setKey('')
|
||||||
await onConnected()
|
await onConnected()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -50,18 +45,16 @@ export function AiturkProvider({ onConnected }: { onConnected: () => Promise<voi
|
|||||||
<h3 className="font-semibold">TurkServis · AITURK</h3>
|
<h3 className="font-semibold">TurkServis · AITURK</h3>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{copy.pitch}</p>
|
<p className="mt-1 text-sm text-muted-foreground">{copy.pitch}</p>
|
||||||
</div>
|
</div>
|
||||||
<AiturkLogin disabled={busy} onConnected={onConnected} />
|
|
||||||
<div className="text-xs text-muted-foreground">{copy.login.orPasteKey}</div>
|
|
||||||
<label className="grid gap-2 text-sm">
|
<label className="grid gap-2 text-sm">
|
||||||
{copy.key}
|
{copy.key}
|
||||||
<Input autoComplete="off" disabled={busy} onChange={e => setKey(e.target.value)}
|
<Input autoComplete="off" disabled={busy} onChange={e => setKey(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) {void connect()} }}
|
onKeyDown={e => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) void connect() }}
|
||||||
placeholder={copy.key} type="password" value={key} />
|
placeholder={copy.key} type="password" value={key} />
|
||||||
</label>
|
</label>
|
||||||
{error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
|
{error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<DocsLink href={AITURK_ACCOUNT_URL}>{copy.getKey}</DocsLink>
|
<DocsLink href={AITURK_ACCOUNT_URL}>{copy.getKey}</DocsLink>
|
||||||
<Button disabled={busy || !key.trim()} onClick={() => void connect()} variant="outline">
|
<Button disabled={busy || !key.trim()} onClick={() => void connect()}>
|
||||||
{busy ? <Loader2 className="animate-spin" /> : null}{busy ? t.common.connecting : t.common.connect}
|
{busy ? <Loader2 className="animate-spin" /> : null}{busy ? t.common.connecting : t.common.connect}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Vendored
-22
@@ -323,14 +323,6 @@ declare global {
|
|||||||
setDisableF12?: (blocked: boolean) => void
|
setDisableF12?: (blocked: boolean) => void
|
||||||
setPreviewShortcutActive?: (active: boolean) => void
|
setPreviewShortcutActive?: (active: boolean) => void
|
||||||
openExternal: (url: string) => Promise<void>
|
openExternal: (url: string) => Promise<void>
|
||||||
/** 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<AiturkLoginStart>
|
|
||||||
poll: () => Promise<AiturkLoginPoll>
|
|
||||||
cancel: () => Promise<{ ok: true }>
|
|
||||||
overwriteCli: () => Promise<{ cli: AiturkCliStatus }>
|
|
||||||
}
|
|
||||||
/** One-shot loopback callback listener for MCP OAuth against remote
|
/** One-shot loopback callback listener for MCP OAuth against remote
|
||||||
* backends (electron/mcp-oauth-callback-ipc.ts): bind on THIS machine,
|
* backends (electron/mcp-oauth-callback-ipc.ts): bind on THIS machine,
|
||||||
* pass redirectUri as client_redirect_uri to mcp.servers.oauth.start,
|
* pass redirectUri as client_redirect_uri to mcp.servers.oauth.start,
|
||||||
@@ -1220,20 +1212,6 @@ export type DesktopBootstrapEvent =
|
|||||||
docsUrl: string
|
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 {
|
export interface HermesApiRequest {
|
||||||
path: string
|
path: string
|
||||||
method?: string
|
method?: string
|
||||||
|
|||||||
@@ -30,28 +30,6 @@ const en = {
|
|||||||
getKey: 'My account and API keys',
|
getKey: 'My account and API keys',
|
||||||
noModels: 'No models are available for this key.',
|
noModels: 'No models are available for this key.',
|
||||||
failed: 'Could not connect to TurkServis.',
|
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: {
|
custom: {
|
||||||
title: 'Custom endpoints',
|
title: 'Custom endpoints',
|
||||||
active: 'Active',
|
active: 'Active',
|
||||||
@@ -101,28 +79,6 @@ const tr: typeof en = {
|
|||||||
getKey: 'Hesabım ve API anahtarlarım',
|
getKey: 'Hesabım ve API anahtarlarım',
|
||||||
noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.',
|
noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.',
|
||||||
failed: 'TurkServis bağlantısı kurulamadı.',
|
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: {
|
custom: {
|
||||||
title: 'Özel API bağlantıları',
|
title: 'Özel API bağlantıları',
|
||||||
active: 'Etkin',
|
active: 'Etkin',
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { connectionScoped, profileScoped } from '@/api/client'
|
|
||||||
import type { CustomEndpointUpdate } from '@/types/hermes'
|
import type { CustomEndpointUpdate } from '@/types/hermes'
|
||||||
|
|
||||||
export const AITURK_API_BASE_URL = 'https://ai.turkservis.online/v1'
|
export const AITURK_API_BASE_URL = 'https://ai.turkservis.online/v1'
|
||||||
export const AITURK_ACCOUNT_URL = 'https://turkservis.online/panel'
|
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 {
|
export function aiturkEndpoint(apiKey: string, model = '', models?: string[]): CustomEndpointUpdate {
|
||||||
return {
|
return {
|
||||||
id: 'turkservis',
|
id: 'turkservis',
|
||||||
|
|||||||
@@ -53,6 +53,44 @@ npm run android:sync --workspaces=false
|
|||||||
üretim imzası içermez ve kurulabilir sürüm olarak paylaşılmamalıdır. Özel üretim
|
üretim imzası içermez ve kurulabilir sürüm olarak paylaşılmamalıdır. Özel üretim
|
||||||
imza anahtarı oluşturulmadı; mağaza hesabı veya ücretli hizmet açılmadı.
|
imza anahtarı oluşturulmadı; mağaza hesabı veya ücretli hizmet açılmadı.
|
||||||
|
|
||||||
|
Yayın adayı için sürüm ve Android sürüm kodu birlikte açıkça verilir:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\android\gradlew.bat -p android assembleRelease '-PaiturkReleaseVersion=0.1.4-beta.1' '-PaiturkReleaseVersionCode=6'
|
||||||
|
```
|
||||||
|
|
||||||
|
Bu komut yine **imzasız** APK üretir; üretim imzasını veya cihaz kabulünü atlamaz.
|
||||||
|
İki parametreden yalnız biri, `-dev` etiketi veya 5 ve daha küçük sürüm kodu
|
||||||
|
verilirse derleme durur. Parametresiz geliştirici derlemesi `0.1.4-dev` / 5 kalır.
|
||||||
|
Sonraki yayınlarda sürüm kodunu son dağıtılmış APK'dan daha büyük seçin; buradaki
|
||||||
|
alt sınır yalnız mevcut geliştirme sürümünü korur, uzak yayın envanterini sorgulamaz.
|
||||||
|
|
||||||
|
12 Eylül 2026: gerçek Gradle `assembleRelease` çalışması bu parametrelerle 55
|
||||||
|
saniyede tamamlandı; `aapt2` uygulama kimliği, `0.1.4-beta.1` / 6 ve debug
|
||||||
|
bayrağının olmadığını doğruladı. Test çıktısının SHA-256 değeri
|
||||||
|
`a93b93b3080ec484cc998ef316528ff7b947fa366b2ff5de375386acc66fffc4`.
|
||||||
|
`apksigner verify` imzasız çıktıyı reddetti (beklenen sonuç). Ayrı gerçek Gradle
|
||||||
|
çalışmaları eksik sürüm kodunu, `0.1.4-dev` yayın etiketini ve sürüm kodu 5'i
|
||||||
|
reddetti. Bu çıktı yayımlanmadı; imza ve cihaz kabulü değildir.
|
||||||
|
|
||||||
|
### Dağıtım APK'sının doğrulanması
|
||||||
|
|
||||||
|
`scripts/verify-android-release.mjs` dağıtım öncesi salt okunur APK kontrolüdür.
|
||||||
|
`--apk`, `--java`, `--apksigner` (Android SDK içindeki apksigner.jar), `--aapt2`,
|
||||||
|
`--certificate` (bağımsız olarak belirlenen üretim sertifikası SHA-256 değeri),
|
||||||
|
`--version` ve `--version-code` parametrelerinin tamamını ister. Sertifika pinini
|
||||||
|
kontrol edilen APK'dan otomatik türetmeyin. Bu araç anahtar üretmez veya APK imzalamaz.
|
||||||
|
|
||||||
|
Gerçek apksigner doğrulaması, v2/v3 imza, tek beklenen sertifika, uygulama kimliği
|
||||||
|
`online.turkservis.aiturk.mobile`, tam sürüm eşleşmesi ve debug bayrağı kontrol edilir.
|
||||||
|
Dosya doğrulama boyunca değişirse sonuç reddedilir. Debug sertifikası ve `-dev`
|
||||||
|
sürümü dağıtım için kabul edilmez. Başarılı rapor APK hash'ini verir fakat açıkça
|
||||||
|
`deviceAcceptanceVerified: false` içerir; fiziksel telefon kabulünün yerine geçmez.
|
||||||
|
|
||||||
|
12 Eylül 2026: mevcut mutual-identity geliştirici APK'sı gerçek SDK araçlarıyla
|
||||||
|
kontrol edildi ve beklenildiği gibi `debug-signing-certificate` ile reddedildi.
|
||||||
|
Üretim sertifikalı pozitif APK doğrulaması henüz yapılmadı.
|
||||||
|
|
||||||
## Uyumlu IDE ve bağlantı servisi
|
## Uyumlu IDE ve bağlantı servisi
|
||||||
|
|
||||||
Bu kaynak ağacındaki masaüstü paketini normal masaüstü build süreciyle derleyin.
|
Bu kaynak ağacındaki masaüstü paketini normal masaüstü build süreciyle derleyin.
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
// Release identity is explicit; ordinary developer builds retain the -dev label.
|
||||||
|
def releaseVersion = providers.gradleProperty('aiturkReleaseVersion').orNull
|
||||||
|
def releaseCode = providers.gradleProperty('aiturkReleaseVersionCode').orNull
|
||||||
|
if ((releaseVersion == null) != (releaseCode == null)) {
|
||||||
|
throw new GradleException('Both aiturkReleaseVersion and aiturkReleaseVersionCode are required')
|
||||||
|
}
|
||||||
|
def selectedVersionCode = 5
|
||||||
|
if (releaseVersion != null) {
|
||||||
|
if (!(releaseVersion ==~ /\d+\.\d+\.\d+(?:-beta\.\d+)?/)) {
|
||||||
|
throw new GradleException('Release version must be X.Y.Z or X.Y.Z-beta.N; -dev is not a release')
|
||||||
|
}
|
||||||
|
if (!(releaseCode ==~ /[1-9]\d{0,9}/) || releaseCode.toLong() <= 5 || releaseCode.toLong() > 2100000000L) {
|
||||||
|
throw new GradleException('Release version code must be greater than 5 and at most 2100000000')
|
||||||
|
}
|
||||||
|
selectedVersionCode = releaseCode.toInteger()
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "online.turkservis.aiturk.mobile"
|
namespace = "online.turkservis.aiturk.mobile"
|
||||||
compileSdk = rootProject.ext.compileSdkVersion
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
@@ -7,8 +24,8 @@ android {
|
|||||||
applicationId "online.turkservis.aiturk.mobile"
|
applicationId "online.turkservis.aiturk.mobile"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 5
|
versionCode selectedVersionCode
|
||||||
versionName "0.1.4-dev"
|
versionName releaseVersion ?: "0.1.4-dev"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { createReadStream } from 'node:fs'
|
||||||
|
import { lstat } from 'node:fs/promises'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { parseArgs } from 'node:util'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
|
export function validateAndroidRelease({ signature, badging, certificate, version, versionCode }) {
|
||||||
|
const expected = certificate.trim().replaceAll(':', '').toLowerCase()
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(expected)) throw new Error('certificate-pin-invalid')
|
||||||
|
if (!/^\d+\.\d+\.\d+(?:-beta\.\d+)?$/.test(version)) throw new Error('release-version-invalid')
|
||||||
|
if (!Number.isSafeInteger(versionCode) || versionCode <= 0) throw new Error('version-code-invalid')
|
||||||
|
const signers = [...signature.matchAll(/^Signer #\d+ certificate SHA-256 digest: ([a-fA-F0-9]{64})\s*$/gm)]
|
||||||
|
if (signers.length !== 1 || signers[0][1].toLowerCase() !== expected) throw new Error('signing-certificate-mismatch')
|
||||||
|
if (/^Signer #\d+ certificate DN:.*\bCN=Android Debug\b/im.test(signature)) throw new Error('debug-signing-certificate')
|
||||||
|
if (!/^Verified using v[23](?:\.\d+)? scheme[^\r\n]*: true\s*$/m.test(signature)) throw new Error('modern-apk-signature-required')
|
||||||
|
const packages = [...badging.matchAll(/^package: name='([^']+)' versionCode='([^']+)' versionName='([^']+)'/gm)]
|
||||||
|
if (packages.length !== 1 || packages[0][1] !== 'online.turkservis.aiturk.mobile') throw new Error('application-id-mismatch')
|
||||||
|
if (packages[0][2] !== String(versionCode) || packages[0][3] !== version) throw new Error('apk-version-mismatch')
|
||||||
|
if (/^application-debuggable\b/m.test(badging)) throw new Error('debuggable-apk')
|
||||||
|
return { applicationId: packages[0][1], version, versionCode, certificateSha256: expected }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regularFile(path) {
|
||||||
|
let current = path
|
||||||
|
while (true) {
|
||||||
|
const info = await lstat(current)
|
||||||
|
if (info.isSymbolicLink()) throw new Error('apk-reparse-path')
|
||||||
|
if (current === path && !info.isFile()) throw new Error('apk-not-regular')
|
||||||
|
const parent = dirname(current)
|
||||||
|
if (parent === current) return
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function digest(path) {
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
for await (const chunk of createReadStream(path)) hash.update(chunk)
|
||||||
|
return hash.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function tool(executable, args) {
|
||||||
|
const env = { ...process.env }
|
||||||
|
delete env.JAVA_TOOL_OPTIONS
|
||||||
|
delete env._JAVA_OPTIONS
|
||||||
|
delete env.JDK_JAVA_OPTIONS
|
||||||
|
const result = spawnSync(executable, args, { env, encoding: 'utf8', windowsHide: true, timeout: 60_000, maxBuffer: 1024 * 1024 })
|
||||||
|
if (result.error || result.status !== 0) throw new Error('android-verification-tool-failed')
|
||||||
|
return result.stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyAndroidRelease(options) {
|
||||||
|
const apk = resolve(options.apk)
|
||||||
|
await regularFile(apk)
|
||||||
|
const before = await lstat(apk)
|
||||||
|
const sha256 = await digest(apk)
|
||||||
|
const signature = tool(resolve(options.java), ['-jar', resolve(options.apksigner), 'verify', '--verbose', '--print-certs', apk])
|
||||||
|
const badging = tool(resolve(options.aapt2), ['dump', 'badging', apk])
|
||||||
|
const identity = validateAndroidRelease({ ...options, signature, badging })
|
||||||
|
await regularFile(apk)
|
||||||
|
const after = await lstat(apk)
|
||||||
|
if (before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || await digest(apk) !== sha256) throw new Error('apk-changed-during-verification')
|
||||||
|
return { verified: true, ...identity, size: after.size, sha256, deviceAcceptanceVerified: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||||
|
const { values } = parseArgs({ options: Object.fromEntries(['apk', 'java', 'apksigner', 'aapt2', 'certificate', 'version', 'version-code'].map(name => [name, { type: 'string' }])) })
|
||||||
|
try {
|
||||||
|
if (Object.keys(values).length !== 7) throw new Error('all-release-verification-arguments-required')
|
||||||
|
console.log(JSON.stringify(await verifyAndroidRelease({ ...values, versionCode: Number(values['version-code']) })))
|
||||||
|
} catch (error) {
|
||||||
|
const code = /^[a-z][a-z0-9-]{1,95}$/.test(error.message) ? error.message : 'android-release-verification-failed'
|
||||||
|
console.error(JSON.stringify({ verified: false, code }))
|
||||||
|
process.exitCode = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { validateAndroidRelease } from '../scripts/verify-android-release.mjs'
|
||||||
|
|
||||||
|
const certificate = 'a'.repeat(64)
|
||||||
|
const fixture = {
|
||||||
|
certificate, version: '0.1.4-beta.1', versionCode: 6,
|
||||||
|
signature: `Verified using v2 scheme (APK Signature Scheme v2): true\nSigner #1 certificate DN: CN=AITURK Release\nSigner #1 certificate SHA-256 digest: ${certificate}\n`,
|
||||||
|
badging: "package: name='online.turkservis.aiturk.mobile' versionCode='6' versionName='0.1.4-beta.1'\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
test('APK release identity requires a pinned certificate and exact version', () => {
|
||||||
|
assert.equal(validateAndroidRelease(fixture).certificateSha256, certificate)
|
||||||
|
assert.throws(() => validateAndroidRelease({ ...fixture, certificate: 'b'.repeat(64) }), /certificate-mismatch/)
|
||||||
|
assert.throws(() => validateAndroidRelease({ ...fixture, versionCode: 7 }), /version-mismatch/)
|
||||||
|
assert.throws(() => validateAndroidRelease({ ...fixture, version: '0.1.4-dev' }), /release-version-invalid/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('debug and ambiguous APK identities are never release-ready', () => {
|
||||||
|
assert.throws(() => validateAndroidRelease({ ...fixture, signature: fixture.signature.replace('AITURK Release', 'Android Debug') }), /debug-signing/)
|
||||||
|
assert.throws(() => validateAndroidRelease({ ...fixture, badging: fixture.badging + 'application-debuggable\n' }), /debuggable-apk/)
|
||||||
|
assert.throws(() => validateAndroidRelease({ ...fixture, badging: fixture.badging.replace('online.turkservis.aiturk.mobile', 'other.application') }), /application-id/)
|
||||||
|
assert.throws(() => validateAndroidRelease({ ...fixture, signature: fixture.signature + `Signer #2 certificate SHA-256 digest: ${certificate}\n` }), /certificate-mismatch/)
|
||||||
|
assert.throws(() => validateAndroidRelease({ ...fixture, signature: fixture.signature.replace(': true', ': false') }), /modern-apk-signature/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Connect the AITURK distribution's media tools to its existing member key."""
|
||||||
|
|
||||||
|
|
||||||
|
def configure_existing_media(cfg: dict) -> bool:
|
||||||
|
"""Migrate an existing member endpoint when the upgraded backend starts."""
|
||||||
|
providers = cfg.get("providers") or {}
|
||||||
|
entries = providers.values() if isinstance(providers, dict) else []
|
||||||
|
for entry in entries:
|
||||||
|
if isinstance(entry, dict) and configure_media(cfg, entry):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def configure_media(cfg: dict, entry: dict) -> bool:
|
||||||
|
if entry.get("base_url", "").rstrip("/") != "https://ai.turkservis.online/v1":
|
||||||
|
return False
|
||||||
|
key_env = entry.get("key_env")
|
||||||
|
if not isinstance(key_env, str) or not key_env:
|
||||||
|
return False
|
||||||
|
servers = cfg.get("mcp_servers")
|
||||||
|
if servers is None:
|
||||||
|
servers = cfg["mcp_servers"] = {}
|
||||||
|
if not isinstance(servers, dict):
|
||||||
|
return False
|
||||||
|
name = "turkservis-media"
|
||||||
|
existing = servers.get(name, {})
|
||||||
|
if not isinstance(existing, dict):
|
||||||
|
return False
|
||||||
|
if existing and existing.get("url") != "https://ai.turkservis.online/mcp":
|
||||||
|
return False
|
||||||
|
updated = {
|
||||||
|
**existing,
|
||||||
|
"url": "https://ai.turkservis.online/mcp",
|
||||||
|
"headers": {"Authorization": "Bearer ${" + key_env + "}"},
|
||||||
|
"timeout": 300,
|
||||||
|
"tools": {"include": ["list_capabilities", "generate_media"]},
|
||||||
|
}
|
||||||
|
if updated == existing:
|
||||||
|
return False
|
||||||
|
servers[name] = updated
|
||||||
|
return True
|
||||||
@@ -405,6 +405,12 @@ def _eager_reconcile_own_session_db() -> None:
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _lifespan(app: "FastAPI"):
|
async def _lifespan(app: "FastAPI"):
|
||||||
|
from hermes_cli.aiturk_media import configure_existing_media
|
||||||
|
from hermes_cli.config import read_raw_config, save_config
|
||||||
|
with _CONFIG_MUTATION_LOCK:
|
||||||
|
media_cfg = read_raw_config()
|
||||||
|
if configure_existing_media(media_cfg):
|
||||||
|
save_config(media_cfg)
|
||||||
app.state.event_channels = {} # dict[str, set]
|
app.state.event_channels = {} # dict[str, set]
|
||||||
app.state.event_lock = asyncio.Lock()
|
app.state.event_lock = asyncio.Lock()
|
||||||
app.state.pty_active_session_files = {} # dict[str, Path]
|
app.state.pty_active_session_files = {} # dict[str, Path]
|
||||||
@@ -8823,6 +8829,9 @@ def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> T
|
|||||||
providers[endpoint_id] = entry
|
providers[endpoint_id] = entry
|
||||||
cfg["providers"] = providers
|
cfg["providers"] = providers
|
||||||
|
|
||||||
|
from hermes_cli.aiturk_media import configure_media
|
||||||
|
configure_media(cfg, entry)
|
||||||
|
|
||||||
if body.make_default:
|
if body.make_default:
|
||||||
cfg["model"] = _apply_main_model_assignment(
|
cfg["model"] = _apply_main_model_assignment(
|
||||||
cfg.get("model", {}), endpoint_id, model, base_url
|
cfg.get("model", {}), endpoint_id, model, base_url
|
||||||
|
|||||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
|||||||
},
|
},
|
||||||
"apps/desktop": {
|
"apps/desktop": {
|
||||||
"name": "aiturk-ide",
|
"name": "aiturk-ide",
|
||||||
"version": "1.0.0-beta.14",
|
"version": "1.0.0-beta.12",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@assistant-ui/core": "0.2.23",
|
"@assistant-ui/core": "0.2.23",
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
---
|
|
||||||
name: aiturk-media
|
|
||||||
description: "Generate images, video and speech or transcribe audio with the user's TurkServis account through the AITURK CLI (`aiturk media`). Use whenever the user asks for an image, video, voice-over or transcript."
|
|
||||||
version: 1.0.0
|
|
||||||
author: AITURK / TurkServis
|
|
||||||
license: MIT
|
|
||||||
platforms: [windows]
|
|
||||||
prerequisites:
|
|
||||||
commands: [aiturk]
|
|
||||||
metadata:
|
|
||||||
hermes:
|
|
||||||
tags: [Media, Image, Video, TTS, STT, AITURK, TurkServis]
|
|
||||||
---
|
|
||||||
|
|
||||||
# AITURK media (image, video, speech, transcription)
|
|
||||||
|
|
||||||
AITURK IDE produces media with the AITURK CLI that is installed next to it. The
|
|
||||||
CLI uses the same TurkServis account; usage is billed to the member's balance.
|
|
||||||
Do not add or enable an MCP media server for this.
|
|
||||||
|
|
||||||
## When to use
|
|
||||||
|
|
||||||
The user asks for an image, a short video, spoken audio from text, or a
|
|
||||||
transcript of an audio file.
|
|
||||||
|
|
||||||
## Steps
|
|
||||||
|
|
||||||
The launcher is `aiturk.cmd`. In PowerShell or cmd type `aiturk`; in a bash-style
|
|
||||||
terminal (Git Bash) type `aiturk.cmd` instead, with the same arguments.
|
|
||||||
|
|
||||||
1. List what the account can use right now. Never rely on a remembered model list.
|
|
||||||
|
|
||||||
```shell
|
|
||||||
aiturk media list --json
|
|
||||||
```
|
|
||||||
|
|
||||||
Use a capability only when its `status` is `available`. `unknown` is an
|
|
||||||
access error; `unavailable` means no usable model. Pick the model the user
|
|
||||||
named, or a fitting `id` from the list. Never invent a model id.
|
|
||||||
|
|
||||||
2. Run one generation. Choose an output file name that does not exist yet.
|
|
||||||
|
|
||||||
```shell
|
|
||||||
aiturk media run image --model <model-id> --text "<what the user asked for>" --output image.png
|
|
||||||
aiturk media run video --model <model-id> --text "<prompt>" --seconds 5 --output clip.mp4
|
|
||||||
aiturk media run tts --model <model-id> --text "<text>" --voice <voice> --output speech.mp3
|
|
||||||
aiturk media run stt --model <model-id> --audio <audio-file> --output transcript.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Confirm the output file exists and has the expected type, then show or link
|
|
||||||
it to the user. Do not retry a failed or interrupted generation automatically;
|
|
||||||
each run is billed.
|
|
||||||
|
|
||||||
## If the command is unavailable
|
|
||||||
|
|
||||||
- `aiturk` and `aiturk.cmd` both not found: try `%LOCALAPPDATA%\TurkServis\AITURK\program\bin\aiturk.cmd`.
|
|
||||||
If that is missing too, tell the user to install the AITURK CLI from
|
|
||||||
https://turkservis.online/indir.
|
|
||||||
- Not signed in or an authentication error: ask the user to run `aiturk login`
|
|
||||||
once, then `aiturk doctor`.
|
|
||||||
- Only while the CLI cannot be used at all may an already configured TurkServis
|
|
||||||
media connection be used instead; say that you did and why.
|
|
||||||
|
|
||||||
Never print API keys, tokens or authorization headers. Without a generation
|
|
||||||
request, list only; do not start a paid trial generation.
|
|
||||||
@@ -1,52 +1,35 @@
|
|||||||
"""AITURK IDE produces media through the AITURK CLI, never by adding an MCP server."""
|
from hermes_cli.aiturk_media import configure_media, configure_existing_media
|
||||||
import importlib.util
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
SKILL = ROOT / "skills" / "media" / "aiturk-media" / "SKILL.md"
|
|
||||||
|
|
||||||
|
|
||||||
def test_backend_never_registers_a_turkservis_media_mcp_server():
|
def test_existing_member_profile_migrates_once_without_changing_provider():
|
||||||
assert importlib.util.find_spec("hermes_cli.aiturk_media") is None
|
entry = {"base_url": "https://ai.turkservis.online/v1", "key_env": "MEMBER_KEY"}
|
||||||
source = (ROOT / "hermes_cli" / "web_server.py").read_text(encoding="utf-8")
|
cfg = {"providers": {"turkservis": dict(entry)}, "model": {"default": "kept"}}
|
||||||
for needle in ("aiturk_media", "turkservis-media", "generate_media", "ai.turkservis.online/mcp"):
|
assert configure_existing_media(cfg)
|
||||||
assert needle not in source
|
assert cfg["providers"]["turkservis"] == entry
|
||||||
|
assert cfg["model"] == {"default": "kept"}
|
||||||
|
assert not configure_existing_media(cfg)
|
||||||
|
|
||||||
|
|
||||||
def test_no_backend_module_writes_the_turkservis_mcp_endpoint_into_config():
|
def test_member_key_is_referenced_without_copying_or_overwriting_other_servers():
|
||||||
offenders = [
|
cfg = {"mcp_servers": {"other": {"url": "https://example.org/mcp"}}}
|
||||||
path.relative_to(ROOT).as_posix()
|
entry = {"base_url": "https://ai.turkservis.online/v1", "key_env": "HERMES_CUSTOM_TURKSERVIS_API_KEY"}
|
||||||
for package in ("hermes_cli", "agent", "tools", "gateway", "tui_gateway")
|
assert configure_media(cfg, entry)
|
||||||
for path in (ROOT / package).rglob("*.py")
|
assert cfg["mcp_servers"]["other"] == {"url": "https://example.org/mcp"}
|
||||||
if "ai.turkservis.online/mcp" in path.read_text(encoding="utf-8", errors="ignore")
|
assert cfg["mcp_servers"]["turkservis-media"]["headers"]["Authorization"] == "Bearer ${HERMES_CUSTOM_TURKSERVIS_API_KEY}"
|
||||||
]
|
assert not configure_media(cfg, entry)
|
||||||
assert offenders == []
|
entry["key_env"] = "ROTATED_MEMBER_KEY"
|
||||||
|
assert configure_media(cfg, entry)
|
||||||
|
assert "ROTATED_MEMBER_KEY" in cfg["mcp_servers"]["turkservis-media"]["headers"]["Authorization"]
|
||||||
|
|
||||||
|
|
||||||
def test_existing_user_mcp_entries_are_left_exactly_as_they_are():
|
def test_other_provider_and_missing_key_do_not_enable_media():
|
||||||
# Startup used to rewrite config.yaml; with the writer gone, a saved config
|
for entry in ({"base_url": "https://example.com/v1", "key_env": "SECRET"}, {"base_url": "https://ai.turkservis.online/v1"}):
|
||||||
# round-trips untouched (including a disabled turkservis-media entry).
|
cfg = {}
|
||||||
before = {
|
assert not configure_media(cfg, entry)
|
||||||
"providers": {"turkservis": {"base_url": "https://ai.turkservis.online/v1", "key_env": "MEMBER_KEY"}},
|
assert cfg == {}
|
||||||
"mcp_servers": {
|
|
||||||
"turkservis-media": {"url": "https://ai.turkservis.online/mcp", "enabled": False},
|
|
||||||
"other": {"url": "https://example.org/mcp"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
source = (ROOT / "hermes_cli" / "web_server.py").read_text(encoding="utf-8")
|
|
||||||
lifespan = source[source.index("async def _lifespan("):]
|
|
||||||
lifespan = lifespan[:lifespan.index("yield")]
|
|
||||||
assert "save_config" not in lifespan and "mcp_servers" not in lifespan
|
|
||||||
assert yaml.safe_load(yaml.safe_dump(before)) == before
|
|
||||||
|
|
||||||
|
|
||||||
def test_bundled_skill_teaches_aiturk_media():
|
def test_explicitly_disabled_connection_stays_disabled():
|
||||||
text = SKILL.read_text(encoding="utf-8")
|
cfg = {"mcp_servers": {"turkservis-media": {"url": "https://ai.turkservis.online/mcp", "enabled": False}}}
|
||||||
meta = yaml.safe_load(text.split("---")[1])
|
configure_media(cfg, {"base_url": "https://ai.turkservis.online/v1", "key_env": "KEY"})
|
||||||
assert meta["name"] == "aiturk-media"
|
assert cfg["mcp_servers"]["turkservis-media"]["enabled"] is False
|
||||||
for phrase in ("aiturk media list --json", "aiturk media run image", "aiturk media run video",
|
|
||||||
"aiturk media run tts", "aiturk media run stt", "aiturk login"):
|
|
||||||
assert phrase in text
|
|
||||||
assert "Do not add or enable an MCP media server" in text
|
|
||||||
Reference in new issue
Block a user