feat(settings): add Codex link controls and authenticated phone bridge
This commit is contained in:
1 parent
1acb5dd90f
commit
55a1beaf9d
22 files changed
+2857
-3
No files matched your search
@@ -0,0 +1,173 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { parseControlResponse, validateControlRequest } from '../codex-link-control'
|
||||
import controlSchema from '../codex-link-control.schema.json'
|
||||
import { CODEX_LINK_CONTROL_SCHEMA_HASH } from '../codex-link-control.types'
|
||||
import type { CodexLinkControlRequest } from '../codex-link-control.types'
|
||||
import { canonicalJson } from '../codex-link-json'
|
||||
|
||||
import { createCodexLinkClient } from './codex-link-client'
|
||||
|
||||
const request: CodexLinkControlRequest = {
|
||||
contract_version: 'codex-link/1',
|
||||
kind: 'settings_get',
|
||||
request_id: 'req-1',
|
||||
operation_id: 'op-1'
|
||||
}
|
||||
|
||||
const response = {
|
||||
...request,
|
||||
kind: 'settings_result',
|
||||
settings: {
|
||||
settings_revision: 1,
|
||||
enabled: true,
|
||||
compression_enabled: true,
|
||||
threshold_tokens: 250000,
|
||||
source_binding_handle: null,
|
||||
compact_route_id: null,
|
||||
legacy_intent_imported: false,
|
||||
ownership_manifest_revision: 0,
|
||||
activation_state: 'pending_runtime_acceptance'
|
||||
},
|
||||
available_sources: []
|
||||
}
|
||||
|
||||
function harness(reply: string = JSON.stringify(response), env: Record<string, string> = {}) {
|
||||
let stdin = ''
|
||||
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
stdin: new PassThrough(),
|
||||
kill: vi.fn()
|
||||
})
|
||||
|
||||
child.stdin.on('data', chunk => {
|
||||
stdin += chunk.toString()
|
||||
})
|
||||
child.stdin.on('finish', () =>
|
||||
queueMicrotask(() => {
|
||||
const bytes = Buffer.from(reply)
|
||||
|
||||
// UTF-8 chunks may end inside a multi-byte character.
|
||||
for (const byte of bytes) {
|
||||
child.stdout.write(Buffer.from([byte]))
|
||||
}
|
||||
|
||||
child.stderr.write('diagnostic that must not become a response')
|
||||
child.emit('close', 0)
|
||||
})
|
||||
)
|
||||
const spawn = vi.fn(() => child)
|
||||
|
||||
const control = createCodexLinkClient({
|
||||
spawn,
|
||||
existsSync: () => true,
|
||||
platform: 'win32',
|
||||
env: { LOCALAPPDATA: 'C:\\Users\\Example User\\AppData\\Local', SystemRoot: 'C:\\Windows', ...env }
|
||||
})
|
||||
|
||||
return { control, spawn, child, stdin: () => stdin }
|
||||
}
|
||||
|
||||
describe('Codex connection control boundary', () => {
|
||||
it('pins the generated control-only schema', () => {
|
||||
expect('sha256:' + createHash('sha256').update(canonicalJson(controlSchema)).digest('hex')).toBe(CODEX_LINK_CONTROL_SCHEMA_HASH)
|
||||
expect(controlSchema.$defs).not.toHaveProperty('runtime_attach')
|
||||
expect(controlSchema.$defs).not.toHaveProperty('checkpoint_commit')
|
||||
})
|
||||
it('uses only fixed command arguments and sends the exact request on stdin with EOF', async () => {
|
||||
const h = harness()
|
||||
expect(await h.control(request)).toEqual(response)
|
||||
expect(h.stdin()).toBe(JSON.stringify(request) + '\n')
|
||||
const [file, args, options] = h.spawn.mock.calls[0] as unknown as [string, string[], any]
|
||||
expect(file).toBe('C:\\Windows\\System32\\cmd.exe')
|
||||
expect(args).toEqual([
|
||||
'/d',
|
||||
'/s',
|
||||
'/c',
|
||||
'""C:\\Users\\Example User\\AppData\\Local\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd" codex-link control --stdin"'
|
||||
])
|
||||
expect(options).toMatchObject({ shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
})
|
||||
it('rejects runtime and arbitrary path/credential commands before spawning', async () => {
|
||||
const h = harness()
|
||||
await expect(h.control({ ...request, kind: 'runtime_attach' })).rejects.toThrow()
|
||||
await expect(h.control({ ...request, path: 'C:\\private' })).rejects.toThrow()
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
it('rejects shell expansion in the entrypoint path', async () => {
|
||||
const h = harness(undefined, { LOCALAPPDATA: 'C:\\Users\\%PATH%\\Local' })
|
||||
await expect(h.control(request)).rejects.toThrow('invalid_entrypoint')
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
it('binds the response to both request and operation identity', () => {
|
||||
expect(() =>
|
||||
parseControlResponse(JSON.stringify({ ...response, operation_id: 'other-chat-op' }), request)
|
||||
).toThrow()
|
||||
expect(() => parseControlResponse(JSON.stringify({ ...response, request_id: 'other-request' }), request)).toThrow()
|
||||
})
|
||||
it('rejects duplicate keys, floats, trailing JSON and invalid UTF8', () => {
|
||||
expect(() =>
|
||||
parseControlResponse(
|
||||
JSON.stringify(response).replace('"settings_revision":1', '"settings_revision":1,"settings_revision":2'),
|
||||
request
|
||||
)
|
||||
).toThrow('duplicate_json_key')
|
||||
expect(() => parseControlResponse(JSON.stringify(response).replace('250000', '250000.0'), request)).toThrow(
|
||||
'floats_or_non_json_type'
|
||||
)
|
||||
expect(() => parseControlResponse(JSON.stringify(response) + '{}', request)).toThrow()
|
||||
expect(() => parseControlResponse(new Uint8Array([0xc3, 0x28]), request)).toThrow()
|
||||
})
|
||||
it('rejects oversized requests and unsafe integer settings', () => {
|
||||
expect(() =>
|
||||
validateControlRequest({
|
||||
...request,
|
||||
kind: 'settings_update',
|
||||
expected_settings_revision: 1,
|
||||
patch: { threshold_tokens: Number.MAX_SAFE_INTEGER + 1 }
|
||||
})
|
||||
).toThrow()
|
||||
expect(() => validateControlRequest({ ...request, operation_id: 'x'.repeat(70000) })).toThrow()
|
||||
})
|
||||
it('does not retry a write after a broken reply', async () => {
|
||||
const h = harness('broken')
|
||||
await expect(h.control({ ...request, kind: 'catalog_sync' })).rejects.toThrow('outcome_unknown')
|
||||
expect(h.spawn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('preserves a structured CLI rejection returned with exit code one', async () => {
|
||||
const rejection = { ...request, kind: 'control_error', settings_revision: 2, error: { code: 'stale_settings_cas', message: 'Ayarlar değişti.', recovery_action: 'retry_control_read' } }
|
||||
const h = harness()
|
||||
h.child.stdin.removeAllListeners('finish')
|
||||
h.child.stdin.on('finish', () => queueMicrotask(() => {
|
||||
h.child.stdout.write(JSON.stringify(rejection))
|
||||
h.child.emit('close', 1)
|
||||
}))
|
||||
expect(await h.control(request)).toEqual(rejection)
|
||||
expect(h.spawn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('preserves exact Unicode when response bytes arrive separately', async () => {
|
||||
const h = harness(
|
||||
JSON.stringify({
|
||||
...response,
|
||||
available_sources: [{ source_binding_handle: 'source-a', label: 'Türkçe é/é 🧪', authorized: true }]
|
||||
})
|
||||
)
|
||||
|
||||
const result = await h.control(request)
|
||||
expect(result.kind === 'settings_result' && result.available_sources[0].label).toBe('Türkçe é/é 🧪')
|
||||
})
|
||||
it('bounds stdout and terminates only the short proxy', async () => {
|
||||
const h = harness('x'.repeat(1024 * 1024 + 1))
|
||||
// Avoid a million small chunks: replace the synthetic finish listener.
|
||||
h.child.stdin.removeAllListeners('finish')
|
||||
h.child.stdin.on('finish', () => queueMicrotask(() => h.child.stdout.emit('data', Buffer.alloc(1024 * 1024 + 1))))
|
||||
await expect(h.control(request)).rejects.toThrow('outcome_unknown')
|
||||
expect(h.child.kill).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { CODEX_LINK_RESPONSE_BYTES, parseControlResponse, validateControlRequest } from '../codex-link-control'
|
||||
import type { CodexLinkControlResponse } from '../codex-link-control.types'
|
||||
|
||||
import { type AiturkCliRunnerDeps, resolveAiturkCliPath } from './aiturk-login'
|
||||
|
||||
type Dependencies = AiturkCliRunnerDeps
|
||||
// Only the fixed entrypoint and fixed arguments go through cmd.exe. All values travel on stdin.
|
||||
// eslint-disable-next-line no-control-regex -- Reject control bytes in executable paths before cmd parsing.
|
||||
const UNSAFE_PATH = /["%^!\u0000-\u001f]/
|
||||
const ABSOLUTE_PATH = /^[A-Za-z]:\\/
|
||||
|
||||
export function createCodexLinkClient(deps: Dependencies) {
|
||||
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const clearTimer = deps.clearTimer ?? (timer => clearTimeout(timer as ReturnType<typeof setTimeout>))
|
||||
|
||||
return async function control(raw: unknown): Promise<CodexLinkControlResponse> {
|
||||
validateControlRequest(raw)
|
||||
const cli = resolveAiturkCliPath(deps)
|
||||
|
||||
if (!cli) {
|
||||
throw new Error('codex-link:cli_unavailable')
|
||||
}
|
||||
|
||||
const systemRoot = String(deps.env.SystemRoot || deps.env.SYSTEMROOT || 'C:\\Windows').replace(/[\\/]+$/, '')
|
||||
|
||||
if (UNSAFE_PATH.test(cli) || UNSAFE_PATH.test(systemRoot) || !ABSOLUTE_PATH.test(systemRoot)) {
|
||||
throw new Error('codex-link:invalid_entrypoint')
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
let timer: unknown
|
||||
let bytes = 0
|
||||
const chunks: Buffer[] = []
|
||||
let child: any
|
||||
|
||||
const finish = (error?: Error, response?: CodexLinkControlResponse) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
|
||||
if (timer !== undefined) {
|
||||
clearTimer(timer)
|
||||
}
|
||||
|
||||
error ? reject(error) : resolve(response!)
|
||||
}
|
||||
|
||||
try {
|
||||
child = deps.spawn(
|
||||
`${systemRoot}\\System32\\cmd.exe`,
|
||||
['/d', '/s', '/c', `""${cli}" codex-link control --stdin"`],
|
||||
{
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: true,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
finish(new Error('codex-link:cli_unavailable'))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timer = setTimer(() => {
|
||||
// The durable manager may have accepted a write. Never repeat it under a new ID.
|
||||
finish(new Error('codex-link:outcome_unknown'))
|
||||
|
||||
try {
|
||||
child.kill()
|
||||
} catch {
|
||||
/* proxy already exited; manager is independent */
|
||||
}
|
||||
}, deps.timeoutMs ?? 30_000)
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
bytes += chunk.length
|
||||
|
||||
if (bytes > CODEX_LINK_RESPONSE_BYTES) {
|
||||
finish(new Error('codex-link:outcome_unknown'))
|
||||
|
||||
try {
|
||||
child.kill()
|
||||
} catch {
|
||||
/* no process tree termination */
|
||||
}
|
||||
} else {
|
||||
chunks.push(Buffer.from(chunk))
|
||||
}
|
||||
})
|
||||
// Drain concurrently, with no history, paths, credentials or uncontrolled diagnostics logged.
|
||||
child.stderr.on('data', () => {})
|
||||
child.stdin.on('error', () => {})
|
||||
child.once('error', () => finish(new Error('codex-link:outcome_unknown')))
|
||||
child.once('close', (code: number | null) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (code !== 0 && code !== 1) {
|
||||
finish(new Error('codex-link:outcome_unknown'))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = parseControlResponse(Buffer.concat(chunks), raw)
|
||||
|
||||
if (code === 1 && response.kind !== 'control_error') {
|
||||
finish(new Error('codex-link:outcome_unknown'))
|
||||
} else {
|
||||
finish(undefined, response)
|
||||
}
|
||||
} catch {
|
||||
finish(new Error('codex-link:outcome_unknown'))
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
child.stdin.end(JSON.stringify(raw) + '\n')
|
||||
} catch {
|
||||
finish(new Error('codex-link:outcome_unknown'))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { WebSocket as RelayWebSocket } from 'ws'
|
||||
|
||||
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
|
||||
import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login'
|
||||
import { createCodexLinkClient } from './codex-link-client'
|
||||
import { createMobileBackendResolver } from './mobile-backend'
|
||||
import {
|
||||
createMobileLink,
|
||||
@@ -17266,6 +17267,16 @@ const aiturkLogin = createAiturkLogin({
|
||||
now: () => Date.now()
|
||||
})
|
||||
|
||||
const codexLinkControl = createCodexLinkClient({ spawn, existsSync: fs.existsSync, env: process.env })
|
||||
ipcMain.handle('aiturk:codex-link:control', async (event, request) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!window || (window !== mainWindow && !instanceWindows.has(window)) ||
|
||||
event.senderFrame !== event.sender.mainFrame) {
|
||||
throw new Error('codex-link:unavailable')
|
||||
}
|
||||
return codexLinkControl(request)
|
||||
})
|
||||
|
||||
// Only the primary window's top-level frame may drive a sign-in.
|
||||
function assertAiturkLoginSender(event) {
|
||||
const contents = mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null
|
||||
@@ -17863,6 +17874,7 @@ function getMobileLink(): MobileLink {
|
||||
const directory = path.join(app.getPath('userData'), 'aiturk-mobile', 'mobile')
|
||||
|
||||
mobileLink = createMobileLink({
|
||||
codexLinkControl,
|
||||
// Same vault file as before (mobile-secrets.bin, safeStorage); older fields are kept.
|
||||
vault: new MobileVault(directory, safeStorage),
|
||||
store: createMobileLinkFileStore(directory, path.join(directory, 'lan-link.json'), fs.promises),
|
||||
|
||||
@@ -271,6 +271,63 @@ async function startProxy(over: Record<string, unknown> = {}) {
|
||||
return { proxy, port, identity, clock, logs, saved, ended, pair, ca: identity.cert }
|
||||
}
|
||||
|
||||
describe('LAN proxy: narrow Codex control', () => {
|
||||
const route = '/api/aiturk/codex-link/control'
|
||||
const body = { contract_version: 'codex-link/1', kind: 'settings_get', request_id: 'phone-read-1', operation_id: 'phone-op-1' }
|
||||
it('requires pairing before invoking local control and never starts a backend', async () => {
|
||||
const calls: unknown[] = []
|
||||
const t = await startProxy({ codexLinkControl: async request => { calls.push(request); return { ...request, kind: 'control_error', settings_revision: 0, error: { code: 'pending', message: 'Not ready', recovery_action: 'none' } } } })
|
||||
const before = resolveCalls
|
||||
try {
|
||||
expect((await call(t.port, 'POST', route, { ca: t.ca, body })).status).toBe(401)
|
||||
expect(calls).toHaveLength(0)
|
||||
const phone = await t.pair()
|
||||
const result = await call(t.port, 'POST', route, { ca: t.ca, token: phone.token, body })
|
||||
expect(result.status).toBe(200)
|
||||
expect(calls).toEqual([body])
|
||||
expect(resolveCalls).toBe(before)
|
||||
expect(JSON.stringify(calls)).not.toContain(phone.token)
|
||||
} finally { await t.proxy.stop() }
|
||||
})
|
||||
it('rejects runtime commands, extra paths, duplicate keys and large bodies', async () => {
|
||||
let calls = 0
|
||||
const t = await startProxy({ codexLinkControl: async () => { calls++; throw new Error('must not execute') } })
|
||||
try {
|
||||
const phone = await t.pair()
|
||||
for (const rawBody of [
|
||||
JSON.stringify({ ...body, kind: 'runtime_attach' }),
|
||||
JSON.stringify({ ...body, path: 'C:\\private' }),
|
||||
JSON.stringify(body).replace('"kind":"settings_get"', '"kind":"settings_get","kind":"status_get"'),
|
||||
'x'.repeat(65537)
|
||||
]) {
|
||||
expect((await call(t.port, 'POST', route, { ca: t.ca, token: phone.token, rawBody })).status).toBe(400)
|
||||
}
|
||||
expect(calls).toBe(0)
|
||||
} finally { await t.proxy.stop() }
|
||||
})
|
||||
it('returns a fixed unknown-outcome code without leaking CLI diagnostics', async () => {
|
||||
const t = await startProxy({ codexLinkControl: async () => { throw new Error('private-key-and-path') } })
|
||||
try {
|
||||
const phone = await t.pair()
|
||||
const result = await call(t.port, 'POST', route, { ca: t.ca, token: phone.token, body })
|
||||
expect(result.status).toBe(503)
|
||||
expect(result.json).toEqual({ error: 'codex_link_outcome_unknown' })
|
||||
expect(result.text).not.toContain('private-key')
|
||||
} finally { await t.proxy.stop() }
|
||||
})
|
||||
it('rejects revoked phones and unsupported methods', async () => {
|
||||
let calls = 0
|
||||
const t = await startProxy({ codexLinkControl: async () => { calls++; return {} } })
|
||||
try {
|
||||
const phone = await t.pair()
|
||||
expect((await call(t.port, 'GET', route, { ca: t.ca, token: phone.token })).status).toBe(405)
|
||||
await t.proxy.revoke(phone.deviceId)
|
||||
expect((await call(t.port, 'POST', route, { ca: t.ca, token: phone.token, body })).status).toBe(401)
|
||||
expect(calls).toBe(0)
|
||||
} finally { await t.proxy.stop() }
|
||||
})
|
||||
})
|
||||
|
||||
describe('LAN proxy: pairing (hello / claim)', () => {
|
||||
it('hello is unauthenticated and reports whether pairing is open', async () => {
|
||||
const t = await startProxy()
|
||||
|
||||
@@ -42,6 +42,9 @@ import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
import net from 'node:net'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { CODEX_LINK_REQUEST_BYTES, validateControlRequest } from '../codex-link-control'
|
||||
import { parseStrictJson } from '../codex-link-json'
|
||||
import type { CodexLinkControlRequest, CodexLinkControlResponse } from '../codex-link-control.types'
|
||||
|
||||
import { loadOrCreateLanIdentity, type MobileLanIdentity, type MobileLanVault } from './mobile-lan-cert'
|
||||
import {
|
||||
@@ -706,6 +709,7 @@ export interface MobileDeviceView {
|
||||
export type PairingEndReason = 'claimed' | 'locked' | 'expired' | 'cancelled'
|
||||
|
||||
export interface LanProxyOptions {
|
||||
codexLinkControl?: (request: CodexLinkControlRequest) => Promise<CodexLinkControlResponse>
|
||||
tls: { key: string; cert: string }
|
||||
/** Must resolve the LOCAL backend or throw (remote mode, backend down). */
|
||||
resolveBackend: (profile: string) => Promise<MobileBackend>
|
||||
@@ -1108,6 +1112,35 @@ export function createLanProxy(options: LanProxyOptions) {
|
||||
return reply(req, res, path, 403, { error: 'forbidden' })
|
||||
}
|
||||
|
||||
if (path === '/api/aiturk/codex-link/control') {
|
||||
if (req.method !== 'POST') return reply(req, res, path, 405, { error: 'method_not_allowed' })
|
||||
if (!options.codexLinkControl) return reply(req, res, path, 503, { error: 'codex_link_unavailable' })
|
||||
if (!/^application\/json(?:;|$)/i.test(String(req.headers['content-type'] ?? ''))) {
|
||||
return reply(req, res, path, 400, { error: 'invalid_request' })
|
||||
}
|
||||
let request: CodexLinkControlRequest
|
||||
try {
|
||||
if (Number(req.headers['content-length'] ?? 0) > CODEX_LINK_REQUEST_BYTES) throw new Error('large_body')
|
||||
let size = 0
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length
|
||||
if (size > CODEX_LINK_REQUEST_BYTES) throw new Error('large_body')
|
||||
chunks.push(Buffer.from(chunk))
|
||||
}
|
||||
const parsed = parseStrictJson(Buffer.concat(chunks), CODEX_LINK_REQUEST_BYTES)
|
||||
validateControlRequest(parsed)
|
||||
request = parsed
|
||||
} catch { return reply(req, res, path, 400, { error: 'invalid_request' }) }
|
||||
// Pairing can be revoked while a slow body is being read.
|
||||
if (!devices.has(device.tokenHash)) return reply(req, res, path, 401, { error: 'unauthorized' })
|
||||
try {
|
||||
const result = await options.codexLinkControl(request)
|
||||
if (!devices.has(device.tokenHash)) return reply(req, res, path, 401, { error: 'unauthorized' })
|
||||
return reply(req, res, path, 200, result)
|
||||
} catch { return reply(req, res, path, 503, { error: 'codex_link_outcome_unknown' }) }
|
||||
}
|
||||
|
||||
const backend = await localBackend(url)
|
||||
|
||||
if (res.destroyed) return
|
||||
@@ -1510,6 +1543,7 @@ export type MobileLinkEvent =
|
||||
{ type: 'state'; state: MobileLinkState } | { type: 'paired'; name: string; state: MobileLinkState }
|
||||
|
||||
export interface MobileLinkDeps {
|
||||
codexLinkControl?: (request: CodexLinkControlRequest) => Promise<CodexLinkControlResponse>
|
||||
vault: MobileLanVault
|
||||
store: MobileLinkStore
|
||||
resolveBackend: (profile: string) => Promise<MobileBackend>
|
||||
@@ -1820,6 +1854,7 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
}
|
||||
|
||||
proxy = createLanProxy({
|
||||
codexLinkControl: deps.codexLinkControl,
|
||||
tls: identity,
|
||||
resolveBackend: async profile => {
|
||||
if (deps.isRemoteMode()) {
|
||||
|
||||
@@ -13,6 +13,7 @@ const hudNativeDrag = hudWindowing?.nativeDrag === true
|
||||
const launchFlags = ipcRenderer.sendSync('hermes:launch-flags')
|
||||
|
||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
codexLink: { control: request => ipcRenderer.invoke('aiturk:codex-link:control', request) },
|
||||
setUiLanguage: (locale: string) => ipcRenderer.send('hermes:ui-language', locale),
|
||||
glassSupported: translucencySupport?.glass === true,
|
||||
translucencySupported: translucencySupport?.translucency === true,
|
||||
|
||||
Reference in new issue
Block a user