Compare commits

...
Author SHA1 Message Date
yilsem 61d765e2df fix(mobile): route phone APIs to selected profile and unblock runtime readiness 2026-10-07 20:10:36 +03:00
yilsemandClaude Opus 5.5 b1db2870c1 ci(gitea): run the approval-gate tests on the Windows runner
This fork had no CI on Gitea (Actions was off; the upstream .github
workflows do not run here). Add one job that runs the protected
instruction-file gate tests and the file-write safety tests on the
Windows runner. Seven upstream tests that cannot pass on Windows (POSIX
paths, symlink privilege, POSIX stat) are deselected by name and listed
in the job log.

Kart: turkservis-20261007-1421-2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwSpve9HuXMy7dHf4RDMzX
2026-10-07 18:12:14 +03:00
yilsemandClaude Opus 5.5 7e4cc0915c AITURK IDE 1.0.0-beta.18: gorunmeyen onay beklemesi yerine aninda "onay gerekli"
A one-shot run (hermes chat --query-file ... -Q, stdin closed, no window)
still installs the prompt_toolkit approval callback but never starts the
prompt UI. The protected instruction-file gate (AGENTS.md / CLAUDE.md /
SOUL.md ...) took the registered callback for a reachable human and waited
out approvals.timeout on a panel painted nowhere. With the profile value of
300000 s the stage job hung for good.

- Gate: in a one-shot run, or when only an automatic approver
  (subagent_auto_approve) is attached, return BLOCKED / APPROVAL REQUIRED
  immediately instead of prompting. Human consent is still required every
  time; yolo and approvals.single_query_mode still do not open this gate.
- One-shot exit prints an "approval_required: {json}" line on stderr
  (session_id, tool_call_id, request_id, targets, reason, applied=false)
  so the launcher can tell "blocked, needs a human" from "still working".
- execute_code: an approval that arrives after the calling script stopped
  waiting (300 s reply timeout, script run timeout, retired kernel cell)
  is not applied.
- Same invisible wait removed from the computer_use and memory-write
  prompts in a one-shot run. The one-shot marker is no longer inherited by
  child processes.
- BLOCKED text no longer breaks on a target path containing braces.

Long-running tools are untouched: no general tool timeout was shortened.

Kart: turkservis-20261007-1421-2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwSpve9HuXMy7dHf4RDMzX
2026-10-07 17:17:05 +03:00
yilsemandClaude Opus 5.5 58dbb4523b AITURK IDE 1.0.0-beta.17: telefon kodu yayini yeniden denenir
Uzun sure acik kalan IDE'de "Telefon ekle" ilk istegi olu bir keep-alive
baglantisinda kaybedebiliyordu; bulusma kaydi yayinlanamayinca kod yolu bes
dakika boyunca "Kodla baglanma su an kullanilamiyor" kaliyordu (QR calisiyordu).

- publishPairOffer: en cok 4 deneme (0,5 / 1,5 / 3 sn); ag hatasi, 5xx ve 429
  yeniden denenir, 422 kalicidir; yaniti kaybolan denemeden sonraki 409 kaydin
  yerinde oldugu anlamina gelir (iptal jetonu yoktur, kayit kendiliginden biter).
- Pencerede "Yeniden dene": ayni kod ve QR ile kaydi yeniden yayinlar.
- Role oturumu kaydi ve "Giris yap" baslangici icin tek ek deneme (zaman asimi
  yeniden denenmez).
- Gunluge yalniz "pair offer published / failed after N tries" duser; kod,
  lookup ve jeton yazilmaz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-05 14:27:59 +03:00
yilsemandClaude Opus 5.5 2ed9a6b2de feat(aiturk): phone link works from any network through an end-to-end encrypted tunnel (1.0.0-beta.16)
The phone link no longer depends on the local network. The IDE dials out to
turkservis.online and bridges tunnel streams to its own TLS proxy on
loopback; the phone runs TLS to the pinned IDE certificate inside the
tunnel, so the relay only carries ciphertext. The proxy binds to 127.0.0.1
by default and enabling the feature never asks for a firewall rule; direct
same-network access is an explicit option. The window shows the real relay,
listener and firewall state, and pairing no longer needs local addresses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-05 04:22:51 +03:00
34 changed files with 5495 additions and 315 deletions

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
# AITURK IDE ajan CI (kart turkservis-20261007-1421-2).
#
# Bu depo bir yukari akis catalidir; .github/workflows altindaki GitHub is akislari Gitea'da kosmaz. .gitea/workflows
# klasoru var oldugu surece Gitea yalniz buradaki is akislarini okur.
#
# Ne kosar: korumali talimat dosyasi onay kapisinin testleri (insana sorulamayan kosuda aninda BLOCKED, gec onayin
# uygulanmamasi, yolo ile kapinin acilmamasi) ve dosya yazma guvenligi testleri. Kosucu Windows'tur (etiket
# windows-latest; 7 Ekim 2026'da cevrimici kosucu hermes-laptop-windows): urunun hedefi Windows x64'tur ve hata
# Windows asama kosusunda gorulmustur.
#
# Ne sayilmaz (durustluk notu): tests/tools/test_file_write_safety.py icindeki 7 test Windows'ta bu degisiklikten
# bagimsiz olarak gecemez; yukari akis bunlari POSIX icin yazmistir (/etc, /private/etc, /boot yollari; yonetici
# ayricaligi isteyen symlink; POSIX stat alanlari). Asagida adlariyla deselect edilir; degismemis 58dbb4523b
# commit'inde de ayni 7 test kaliyordu (7 Ekim 2026 olcumu). Tam yukari akis test takimi burada KOSMAZ.
name: ci
on:
push:
branches:
- aiturk/main
- fix/ide-windows-vcruntime
workflow_dispatch:
permissions:
contents: read
jobs:
mobile-link-windows:
runs-on: windows-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
github-server-url: https://gitea.twinpay.one
- name: Mobil TLS, profil ve baslatma regresyonlari
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
$deps = Join-Path $env:RUNNER_TEMP "mobile-tests-$env:GITHUB_RUN_ID"
New-Item -ItemType Directory -Force -Path $deps | Out-Null
npm install --prefix $deps --no-save --ignore-scripts --no-audit --no-fund --legacy-peer-deps vitest@4.1.10 ws@8.21.1
if ($LASTEXITCODE -ne 0) { throw 'Test bagimliliklari kurulamadi' }
New-Item -ItemType Junction -Path node_modules -Target (Join-Path $deps 'node_modules') | Out-Null
node node_modules/vitest/vitest.mjs run --root apps/desktop --config vitest.mobile.config.ts
exit $LASTEXITCODE
approval-gate-windows:
runs-on: windows-latest
timeout-minutes: 40
steps:
- name: Kaynagi al
uses: actions/checkout@v4
with:
github-server-url: https://gitea.twinpay.one
- name: Ortami kur
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
"kosucu: $env:COMPUTERNAME / $env:USERNAME"
"checkout SHA: $(git rev-parse HEAD) / beklenen: $env:GITHUB_SHA"
$py = $null
$launcher = Get-Command py -ErrorAction SilentlyContinue
if ($launcher) { $py = (& $launcher.Source -3 -c 'import sys; print(sys.executable)').Trim() }
if (-not $py) {
$cmd = Get-Command python -ErrorAction SilentlyContinue
if ($cmd -and $cmd.Source -notlike '*\WindowsApps\*') { $py = $cmd.Source }
}
if (-not $py) { throw 'Python bulunamadi' }
"onyukleme python: $py"
& $py -m pip install --quiet --disable-pip-version-check --target "$env:RUNNER_TEMP\uvboot" uv
if ($LASTEXITCODE -ne 0) { throw 'uv kurulamadi' }
$env:PYTHONPATH = "$env:RUNNER_TEMP\uvboot"
& $py -m uv venv --python 3.11 "$env:RUNNER_TEMP\ci-venv"
if ($LASTEXITCODE -ne 0) { throw 'venv olusturulamadi' }
& $py -m uv pip install --python "$env:RUNNER_TEMP\ci-venv\Scripts\python.exe" -e . pytest
if ($LASTEXITCODE -ne 0) { throw 'bagimliliklar kurulamadi' }
$env:PYTHONPATH = ''
& "$env:RUNNER_TEMP\ci-venv\Scripts\python.exe" --version
- name: Onay kapisi testleri
shell: powershell
run: |
$ErrorActionPreference = 'Continue'
$env:PYTHONDONTWRITEBYTECODE = '1'
$env:PYTHONUTF8 = '1'
$venv = "$env:RUNNER_TEMP\ci-venv\Scripts\python.exe"
$f = 'tests/tools/test_file_write_safety.py'
$sayilmayan = @(
"${f}::TestCheckSensitivePathMacOSBypass::test_etc_hosts_blocked",
"${f}::TestCheckSensitivePathMacOSBypass::test_private_etc_hosts_blocked",
"${f}::TestCheckSensitivePathMacOSBypass::test_private_etc_ssh_config_blocked",
"${f}::TestCheckSensitivePathMacOSBypass::test_private_var_blocked",
"${f}::TestCheckSensitivePathMacOSBypass::test_boot_still_blocked",
"${f}::TestAtomicWrite::test_patch_routes_through_atomic_write",
"${f}::TestProtectedInstructionFiles::test_symlink_to_protected_file_is_gated"
)
$deselect = @()
foreach ($t in $sayilmayan) { $deselect += '--deselect'; $deselect += $t }
"CI OZETI: Windows'ta sayilmayan (deselect) test sayisi: $($sayilmayan.Count)"
$sayilmayan | ForEach-Object { " sayilmadi: $_" }
& $venv -m pytest tests/tools/test_protected_instruction_unattended.py $f `
tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py `
tests/cli/test_single_query_session_finalize.py @deselect -q -p no:cacheprovider -rf
$kod = $LASTEXITCODE
"pytest cikis kodu: $kod"
exit $kod
@@ -0,0 +1,32 @@
# Mobile connection repair — beta.19
The paired phone previously resolved the desktop's active profile. If that
profile's startup promise was waiting for first-run setup, every phone API
request waited too, even while the default backend was already serving.
REST and WebSocket requests now resolve the phone's explicit `profile` query
(default when absent). Aggregate `profile=all` reaches the default process
without rewriting the downstream query. Invalid profile identifiers, remote
backends and a missing local runtime are refused; phone requests do not install
software or silently fall back to a different profile. Resolution has an eight
second deadline; this is not an agent, tool or scheduled-job execution deadline.
Shared-runtime completion wakes only a waiting setup for the same runtime root.
Primary startup then resolves its own profile again. This does not grant
installation consent or substitute a sibling profile's backend connection.
## Evidence
- 94 mobile transport, certificate, relay, profile and startup regression tests.
- Electron TypeScript type check.
- Opt-in `mobile-live.test.ts`, with `AITURK_TEST_BACKEND_PORT`: real local
backend behind the production TLS proxy, pinned certificate, screen reads,
cron create/update/pause/resume/delete and chat WebSocket ping. Test-created
job is deleted; existing jobs are untouched. This test does not trigger an
agent job or claim physical-phone/cellular acceptance.
- Public relay hello was separately exercised using Android's production
OkHttp/Conscrypt stack. A successful hello alone does not prove API readiness.
Publication, installed beta.19 and physical-phone acceptance are separate gates.
Existing phone security exclusions are unchanged. Unsupported methods and
features are not claimed to be implemented by this connection repair.
@@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
AITURK_CLI_KEY_HOLD_MS,
AITURK_LOGIN_START_RETRY_DELAY_MS,
type AiturkCliExit,
AiturkLoginError,
createAiturkCliRunner,
@@ -38,6 +39,8 @@ const approved = { state: 'approved', key: IDE_KEY, cliKey: CLI_KEY, account: 'a
function harness(
options: {
start?: { status: number; body?: unknown }
/** Thrown by successive `device-auth/start` calls before any answer is given. */
startErrors?: Error[]
polls?: ({ status: number; body?: unknown } | Error)[]
cliExits?: AiturkCliExit[]
validate?: unknown
@@ -47,6 +50,7 @@ function harness(
) {
const calls: { url: string; init: any }[] = []
const polls = [...(options.polls ?? [])]
const startErrors = [...(options.startErrors ?? [])]
const cliExits = [...(options.cliExits ?? [0])]
const clock = { now: 1_000_000 }
const timers: { fn: () => void; ms: number; cleared: boolean }[] = []
@@ -55,6 +59,12 @@ function harness(
calls.push({ url, init })
if (url.endsWith('/start')) {
const failure = startErrors.shift()
if (failure) {
throw failure
}
const r = options.start ?? { status: 201, body: startBody() }
return { status: r.status, json: async () => r.body }
@@ -154,6 +164,47 @@ describe('aiturk login: start', () => {
await expect(harness({ start: { status: 500 } }).login.start()).rejects.toThrow('aiturk-login:unavailable')
})
it('tries start once more after a thrown network error (first request of a long-idle process)', async () => {
const h = harness({ startErrors: [new TypeError('fetch failed')] })
const pending = h.login.start()
await vi.waitFor(() => expect(h.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
expect(AITURK_LOGIN_START_RETRY_DELAY_MS).toBe(500)
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
expect(h.openExternal).not.toHaveBeenCalled()
h.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
expect(await pending).toEqual({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: 1_000_000 + 600_000 })
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
// One code only: the approval page is opened once.
expect(h.openExternal).toHaveBeenCalledExactlyOnceWith(URI)
})
it('gives up after the second network error, and never retries an HTTP answer or a timeout', async () => {
const twice = harness({ startErrors: [new TypeError('fetch failed'), new TypeError('fetch failed')] })
const failing = twice.login.start().catch(e => e)
await vi.waitFor(() => expect(twice.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
twice.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
expect((await failing).message).toBe('aiturk-login:unavailable')
expect(twice.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
expect(twice.timers.filter(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toHaveLength(1)
for (const status of [500, 503, 429, 422]) {
const answered = harness({ start: { status } })
await expect(answered.login.start()).rejects.toThrow(/^aiturk-login:(?:unavailable|rate-limited)$/)
expect(answered.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
expect(answered.timers).toHaveLength(0)
}
const timedOut = harness({ startErrors: [new DOMException('The operation was aborted due to timeout', 'TimeoutError')] })
await expect(timedOut.login.start()).rejects.toThrow('aiturk-login:unavailable')
expect(timedOut.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
expect(timedOut.timers).toHaveLength(0)
})
it('rejects malformed codes', async () => {
await expect(
harness({ start: { status: 201, body: startBody({ userCode: 'abcd2345' }) } }).login.start()
+24 -9
View File
@@ -22,6 +22,8 @@
export const AITURK_LOGIN_ORIGIN = 'https://turkservis.online'
export const AITURK_LOGIN_FETCH_TIMEOUT_MS = 20_000
/** Wait before the single second try of `device-auth/start` after a thrown network error. */
export const AITURK_LOGIN_START_RETRY_DELAY_MS = 500
export const AITURK_CLI_TIMEOUT_MS = 60_000
export const AITURK_CLI_KEY_HOLD_MS = 5 * 60_000
@@ -237,16 +239,29 @@ export function createAiturkLogin(deps: AiturkLoginDeps) {
await cancelOnServer(previous.deviceCode)
}
let response: AiturkFetchResponse
let response: AiturkFetchResponse | undefined
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')
// A long-idle process can fail its first request on a dead pooled
// connection. Only a THROWN network error (no HTTP answer, so no code was
// handed to us) is tried once more; an HTTP answer is never retried, so
// one start cannot knowingly leave two codes behind. A timeout already
// used its whole budget and is not retried.
for (let attempt = 1; response === undefined; attempt++) {
try {
response = await post('/v1/aiturk/device-auth/start', null, {
client: 'ide',
device_name: deviceName(deps.hostname()),
also_cli: true
})
} catch (error) {
const name = (error as null | { name?: unknown })?.name
if (attempt >= 2 || name === 'TimeoutError' || name === 'AbortError') {
throw new AiturkLoginError('unavailable')
}
await new Promise<void>(resolve => void setTimer(resolve, AITURK_LOGIN_START_RETRY_DELAY_MS))
}
}
if (response.status === 429) {
@@ -131,3 +131,37 @@ test('remote apply without a waiter has no first-run side effects', async () =>
assert.equal(hidden, 0)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
})
test('a completed shared runtime wakes only its own pending setup, without granting installation consent', async () => {
let hidden = 0
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
assert.equal(gate.runtimeAvailable('/tmp/unrelated-runtime'), false)
assert.equal(await settledState(pending), 'pending')
assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), true)
assert.equal(await pending, 'runtime-ready')
assert.equal(gate.isLocalBootstrapConfirmed(), false)
assert.equal(hidden, 1)
assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), false)
})
test('late runtime readiness does not revive a reset or remotely resolved waiter', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
gate.resetForRetry()
assert.equal(await pending, 'reset')
assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), false)
})
test('runtime completed before wait does not leave a stale setup prompt pending', async () => {
let prompts = 0
const gate = createFirstRunSetupGate({
isRuntimeAvailable: backend => backend.activeRoot === bootstrapBackend.activeRoot,
promptChoice: () => prompts++,
stuckAfterMs: 0
})
assert.equal(await gate.wait(bootstrapBackend), 'runtime-ready')
assert.equal(gate.hasWaiter(), false)
assert.equal(gate.isLocalBootstrapConfirmed(), false)
assert.equal(prompts, 0)
})
+30 -2
View File
@@ -1,3 +1,5 @@
import path from 'node:path'
interface FirstRunSetupBackend {
activeRoot?: string
kind?: string
@@ -5,6 +7,7 @@ interface FirstRunSetupBackend {
}
interface FirstRunSetupGateOptions {
isRuntimeAvailable?: (backend: FirstRunSetupBackend) => boolean
hideChoice?: () => void
log?: (message: string) => void
onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void
@@ -12,9 +15,10 @@ interface FirstRunSetupGateOptions {
stuckAfterMs?: number
}
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset'
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset' | 'runtime-ready'
export function createFirstRunSetupGate({
isRuntimeAvailable,
hideChoice,
log,
onStuck,
@@ -24,6 +28,7 @@ export function createFirstRunSetupGate({
let localBootstrapConfirmed = false
let waiter: {
activeRoot?: string
promise: Promise<FirstRunSetupDecision>
resolve: (decision: FirstRunSetupDecision) => void
} | null = null
@@ -65,6 +70,13 @@ export function createFirstRunSetupGate({
return 'continue-local' as const
}
// Recheck synchronously before registering the waiter: another profile
// may have completed installation before this stale descriptor arrived.
if (backend && isRuntimeAvailable?.(backend)) {
if (backend.activeRoot) runtimeAvailable(backend.activeRoot)
return 'runtime-ready' as const
}
if (waiter) {
return waiter.promise
}
@@ -78,7 +90,7 @@ export function createFirstRunSetupGate({
resolveWaiter = resolve
})
waiter = { promise, resolve: resolveWaiter }
waiter = { activeRoot: backend?.activeRoot, promise, resolve: resolveWaiter }
return promise
}
@@ -102,6 +114,21 @@ export function createFirstRunSetupGate({
settleWaiter('continue-local')
}
// A sibling profile may finish installing the SAME shared runtime while the
// primary boot is awaiting a setup choice. Re-resolve it; do not interpret
// this as permission for another installation, or reuse the sibling's
// profile/backend descriptor.
const runtimeAvailable = (root: string) => {
const canonical = (value: string) => {
const absolute = path.resolve(value)
return process.platform === 'win32' ? absolute.toLowerCase() : absolute
}
if (!waiter?.activeRoot || canonical(waiter.activeRoot) !== canonical(root)) return false
settleWaiter('runtime-ready')
hideChoice?.()
return true
}
const resetForRetry = () => {
// Reset paths are followed by a renderer reload / fresh startHermes() call.
// Settle the old boot explicitly so it cannot fall through into local
@@ -140,6 +167,7 @@ export function createFirstRunSetupGate({
isLocalBootstrapConfirmed,
resetForRepair,
resetForRetry,
runtimeAvailable,
shouldGate,
wait
}
+60 -21
View File
@@ -1,4 +1,4 @@
import { execFileSync, spawn } from 'node:child_process'
import { execFile, execFileSync, spawn } from 'node:child_process'
import crypto from 'node:crypto'
import fs from 'node:fs'
import http from 'node:http'
@@ -32,9 +32,11 @@ import {
shell,
systemPreferences
} from 'electron'
import { WebSocket as RelayWebSocket } from 'ws'
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login'
import { createMobileBackendResolver } from './mobile-backend'
import {
createMobileLink,
createMobileLinkFileStore,
@@ -2074,6 +2076,10 @@ function hideFirstRunSetupChoice() {
function getFirstRunSetupGate() {
if (!firstRunSetupGate) {
firstRunSetupGate = createFirstRunSetupGate({
isRuntimeAvailable: backend =>
backend.activeRoot === ACTIVE_HERMES_ROOT &&
resolveHermesBackend(['--profile', primaryProfileKey(), 'serve', '--host', '127.0.0.1', '--port', '0']).kind !==
'bootstrap-needed',
hideChoice: hideFirstRunSetupChoice,
log: rememberLog,
onStuck: (_backend, stuckAfterMs) => {
@@ -5258,6 +5264,11 @@ async function ensureRuntime(backend) {
error: null
})
// Runtime installation is shared; profile processes and their data are not.
// Wake only a setup waiter for this exact installed root, then let primary
// startup resolve its own profile again (never adopt a pool connection).
firstRunSetupGate?.runtimeAvailable(ACTIVE_HERMES_ROOT)
return backend
}
@@ -6794,7 +6805,7 @@ function buildApplicationMenu() {
const checkForUpdatesItem = {
label: 'Check for Updates…',
click: () => IS_PACKAGED ? void shell.openExternal(AITURK_PRODUCT.downloads) : sendOpenUpdatesRequested()
click: () => (IS_PACKAGED ? void shell.openExternal(AITURK_PRODUCT.downloads) : sendOpenUpdatesRequested())
}
if (IS_MAC) {
@@ -17645,8 +17656,11 @@ async function getUninstallSummary() {
async function runDesktopUninstall(_mode) {
// The upstream cleanup script removes Hermes-specific shortcuts, registry
// entries and global launchers. AITURK is owned by its OS package manager.
return { ok: false, error: 'aiturk-os-uninstaller',
message: 'AITURK IDE işletim sisteminin uygulama yöneticisinden kaldırılır.' }
return {
ok: false,
error: 'aiturk-os-uninstaller',
message: 'AITURK IDE işletim sisteminin uygulama yöneticisinden kaldırılır.'
}
}
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
@@ -17852,30 +17866,50 @@ function getMobileLink(): MobileLink {
// 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),
resolveBackend: async () => {
if (mobileLinkRemoteMode()) {
throw new Error('aiturk-mobile:remote_mode')
}
const connection = await ensureBackend(primaryProfileKey())
if (connection.mode !== 'local') {
throw new Error('aiturk-mobile:remote_mode')
}
return { baseUrl: connection.baseUrl, token: connection.token }
},
resolveBackend: createMobileBackendResolver({
isRemote: profile => mobileLinkRemoteMode() || profileHasRemoteOverride(profile),
runtimeNeedsInstall: profile =>
resolveHermesBackend(['--profile', profile, 'serve', '--host', '127.0.0.1', '--port', '0']).kind ===
'bootstrap-needed',
ensureBackend
}),
isRemoteMode: mobileLinkRemoteMode,
firewall: createWindowsFirewall({ spawn, platform: process.platform, env: process.env }),
firewall: createWindowsFirewall({
spawn,
platform: process.platform,
env: process.env,
// Read-only rule query (fixed arguments, no shell, no elevation). netsh answers in the
// console code page; latin1 keeps every byte and the parser needs no translated word.
run: (file, args) =>
new Promise(resolve => {
execFile(
file,
args,
{ windowsHide: true, timeout: 8000, encoding: 'latin1', maxBuffer: 256 * 1024 },
(error, stdout) => {
const code = error
? typeof (error as { code?: unknown }).code === 'number'
? ((error as { code?: unknown }).code as number)
: null
: 0
resolve({ code, stdout: String(stdout ?? '') })
}
)
})
}),
fetch: (url, init) => fetch(url, init as RequestInit),
// Outbound relay socket (turkservis.online) and the pinned loopback sockets to our own proxy.
WebSocket: RelayWebSocket,
addresses: async () => selectLanAddresses(os.networkInterfaces(), await defaultRouteAddress()),
version: app.getVersion(),
pcName: os.hostname(),
platform: process.platform,
// Method + path + status only (no token, code, query or body). Successful
// API traffic is not logged; pairing and refusals are, for diagnosis.
// API traffic is not logged; pairing and refusals are, for diagnosis. Relay
// lines are status changes only ("relay connected", "relay failed:<code>").
log: line => {
if (/ \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) {
if (/^(?:relay|pair) /.test(line) || / \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) {
rememberLog(`[mobile-lan] ${line}`)
}
}
@@ -17921,7 +17955,12 @@ app.whenReady().then(() => {
}
return ensureBackend(profile)
}
}).then(stop => app.once('will-quit', () => { void stop() }))
})
.then(stop =>
app.once('will-quit', () => {
void stop()
})
)
.catch(() => rememberLog('[chrome] Native bridge could not start.'))
}
// Warm the login-shell PATH resolution immediately so it usually completes
@@ -0,0 +1,48 @@
import { expect, it, vi } from 'vitest'
import { createMobileBackendResolver } from './mobile-backend'
const local = { mode: 'local', baseUrl: 'http://127.0.0.1:12345', token: 'test-session' }
it('uses the phone profile even while another desktop profile is awaiting setup', async () => {
const ensureBackend = vi.fn(async (profile: string) => {
if (profile === 'fusion-frontier') return new Promise<typeof local>(() => {})
return local
})
const resolve = createMobileBackendResolver({
isRemote: () => false,
runtimeNeedsInstall: () => false,
ensureBackend
})
expect(await resolve('default')).toEqual({ baseUrl: local.baseUrl, token: local.token })
expect(ensureBackend).toHaveBeenCalledExactlyOnceWith('default')
})
it('never bootstraps an absent runtime or silently replaces a remote profile with default', async () => {
const ensureBackend = vi.fn(async () => local)
const resolve = createMobileBackendResolver({
isRemote: profile => profile === 'remote',
runtimeNeedsInstall: () => true,
ensureBackend
})
await expect(resolve('default')).rejects.toThrow('runtime_not_ready')
await expect(resolve('remote')).rejects.toThrow('remote_mode')
expect(ensureBackend).not.toHaveBeenCalled()
})
it('preserves an explicitly selected local profile and rejects remote-mode changes during resolution', async () => {
let remote = false
const ensureBackend = vi.fn(async () => local)
const resolve = createMobileBackendResolver({
isRemote: () => remote,
runtimeNeedsInstall: () => false,
ensureBackend
})
await resolve('kodlama')
expect(ensureBackend).toHaveBeenCalledExactlyOnceWith('kodlama')
ensureBackend.mockImplementationOnce(async () => {
remote = true
return local
})
await expect(resolve('kodlama')).rejects.toThrow('remote_mode')
})
+18
View File
@@ -0,0 +1,18 @@
/** Mobile routing is explicit and cannot start a first-run installer. */
export function createMobileBackendResolver<Connection extends { mode: string; baseUrl: string; token: string }>({
isRemote,
runtimeNeedsInstall,
ensureBackend
}: {
isRemote: (profile: string) => boolean
runtimeNeedsInstall: (profile: string) => boolean
ensureBackend: (profile: string) => Promise<Connection>
}) {
return async (profile: string) => {
if (isRemote(profile)) throw new Error('aiturk-mobile:remote_mode')
if (runtimeNeedsInstall(profile)) throw new Error('aiturk-mobile:runtime_not_ready')
const connection = await ensureBackend(profile)
if (connection.mode !== 'local' || isRemote(profile)) throw new Error('aiturk-mobile:remote_mode')
return { baseUrl: connection.baseUrl, token: connection.token }
}
}
+63 -29
View File
@@ -6,56 +6,72 @@ import { drawQr } from './mobile/qr.mjs'
const TEXT = {
tr: {
eyebrow: 'AITURK · BİLGİSAYAR', title: 'Telefon bağlantısı',
intro: 'AITURK Mobil uygulaması, aynı Wi-Fi ağındayken bu bilgisayardaki IDE konuşmalarınızı görür ve sohbet eder. Hesap girişi gerekmez.',
enabled: 'Telefon bağlantısı açık', disabled: 'Telefon bağlantısı kapalı',
intro: 'Telefonunuz internetin olduğu her yerden bu bilgisayardaki IDE’ye bağlanır; aynı Wi-Fi gerekmez. Bağlantı uçtan uca şifrelidir. Bilgisayar açık ve AITURK IDE çalışıyor olmalı.',
toggle: 'Telefon bağlantısını kullan',
remote: 'IDE şu an uzak bir ağ geçidine bağlı. Telefon bağlantısı yalnız yerel IDE bağlantısıyla çalışır; Ayarlar’dan yerel bağlantıyı seçin.',
firewallNeeded: 'Windows Güvenlik Duvarı izni henüz verilmedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.',
firewallDenied: 'Windows izin penceresi reddedildi; Güvenlik Duvarı kuralı eklenemedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.',
firewallFailed: 'Windows Güvenlik Duvarı kuralı eklenemedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.',
retry: 'Yeniden dene', addPhone: 'Telefon ekle', cancel: 'Vazgeç',
relayLabel: 'İnternet üzerinden bağlantı', lanLabel: 'Aynı Wi-Fi’da doğrudan bağlantı',
relay: { connected: 'hazır', connecting: 'bağlanıyor…', failed: 'kurulamadı (yeniden denenecek)', idle: 'beklemede (telefon eklenince bağlanır)',
replaced: 'başka bir AITURK IDE aynı kaydı kullanıyor (30 sn sonra yeniden denenecek)', busy: 'servis dolu (yeniden denenecek)' },
lanOpen: port => `açık (bağlantı noktası ${port})`, lanOff: 'kapalı', lanIdle: 'izin verildi (telefon eklenince açılır)',
lanNoRule: 'Windows Güvenlik Duvarı izni yok', lanDenied: 'kapalı (Windows izin penceresi reddedildi)', lanFailed: 'kapalı (Güvenlik Duvarı kuralı eklenemedi)',
lanAllow: 'Aynı Wi-Fi’da doğrudan bağlantıya da izin ver', allow: 'İzin ver', lanDisallow: 'Doğrudan bağlantıyı kapat',
reissued: 'Röle kimliği yenilendi. Daha önce eklenen telefonları yeniden eşleştirin: telefonda bu bilgisayarı kaldırıp “Telefon ekle” ile yeniden bağlayın.',
addPhone: 'Telefon ekle', cancel: 'Vazgeç',
scan: 'Telefonda AITURK Mobil → “Bilgisayara bağlan” → bu QR kodunu okutun.',
orCode: 'ya da telefonda şu kodu yazın:',
codePending: 'Kodla bağlanma hazırlanıyor…', codeReady: 'Kodla bağlanma hazır.',
codeUnavailable: 'Kodla bağlanma şu an kullanılamıyor; QR kodunu okutun.',
sameWifi: 'Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.',
codeUnavailable: 'Kodla bağlanma şu an kullanılamıyor; QR kodunu okutun.', codeRetry: 'Yeniden dene',
securityCode: code => `Güvenlik kodu: ${code} (telefonda da aynı kod görünmeli)`,
anyNetwork: 'Telefon ve bilgisayar aynı ağda olmak zorunda değil.', sameWifi: 'Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.',
countdown: (m, s) => `Kod ${m}:${s} sonra geçersiz olur`, expired: 'Kodun süresi doldu. Yeniden “Telefon ekle” seçin.',
devices: 'Bağlı telefonlar', noDevices: 'Henüz bağlı telefon yok.',
added: 'Eklenme', lastSeen: 'Son görülme', online: 'şu an bağlı', remove: 'Kaldır',
removed: name => `${name} kaldırıldı.`, paired: name => `${name} bağlandı.`,
footer: port => `Bağlantı noktası: ${port}`, removeRule: 'Güvenlik Duvarı kuralını kaldır', ruleRemoved: 'Güvenlik Duvarı kuralı kaldırıldı.',
removeRule: 'Güvenlik Duvarı kuralını kaldır', ruleRemoved: 'Güvenlik Duvarı kuralı kaldırıldı.',
renew: 'Röle kimliğini yenile', renewed: 'Röle kimliği yenilendi. Telefonları yeniden eşleştirin.',
renewConfirm: 'Röle kimliği yenilenirse bağlı telefonların hepsi kaldırılır ve yeniden eşleştirilmeleri gerekir. Devam edilsin mi?',
errors: {
remote_mode: 'IDE uzak ağ geçidine bağlıyken telefon bağlantısı açılamaz.',
vault_unavailable: 'Windows güvenli anahtar kasası kullanılamıyor; telefon bağlantısı açılamadı.',
not_enabled: 'Önce telefon bağlantısını açın.',
no_lan_address: 'Bu bilgisayar bir yerel ağa (Wi-Fi ya da kablo) bağlı görünmüyor.',
no_lan_address: 'Bu bilgisayar bir yerel ağa (Wi-Fi ya da kablo) bağlı görünmüyor ve internet üzerinden bağlantı da kurulamadı.',
relay_unavailable: 'İnternete ulaşılamıyor, yeniden deneyin.',
port_unavailable: 'Boş bağlantı noktası bulunamadı (47321–47340 kullanımda).',
failed: 'İşlem tamamlanamadı. Yeniden deneyin.',
},
},
en: {
eyebrow: 'AITURK · COMPUTER', title: 'Phone connection',
intro: 'The AITURK Mobile app can see and continue this computer’s IDE conversations while on the same Wi-Fi network. No account sign-in is needed.',
enabled: 'Phone connection is on', disabled: 'Phone connection is off',
intro: 'Your phone connects to the IDE on this computer from anywhere with internet access; the same Wi-Fi is not needed. The connection is end-to-end encrypted. The computer must be on and AITURK IDE must be running.',
toggle: 'Use phone connection',
remote: 'The IDE is connected to a remote gateway. Phone connection only works with the local IDE connection; choose the local connection in Settings.',
firewallNeeded: 'Windows Firewall permission has not been granted yet. Without it the phone may not reach this computer.',
firewallDenied: 'The Windows permission prompt was declined, so the Firewall rule was not added. Without it the phone may not reach this computer.',
firewallFailed: 'The Windows Firewall rule could not be added. Without it the phone may not reach this computer.',
retry: 'Try again', addPhone: 'Add a phone', cancel: 'Cancel',
relayLabel: 'Connection over the internet', lanLabel: 'Direct connection on the same Wi-Fi',
relay: { connected: 'ready', connecting: 'connecting…', failed: 'could not be set up (will retry)', idle: 'waiting (connects once a phone is added)',
replaced: 'another AITURK IDE is using the same registration (will retry in 30 s)', busy: 'service is full (will retry)' },
lanOpen: port => `on (port ${port})`, lanOff: 'off', lanIdle: 'allowed (opens once a phone is added)',
lanNoRule: 'no Windows Firewall permission', lanDenied: 'off (the Windows permission prompt was declined)', lanFailed: 'off (the Firewall rule could not be added)',
lanAllow: 'Also allow a direct connection on the same Wi-Fi', allow: 'Allow', lanDisallow: 'Turn direct connection off',
reissued: 'The relay identity was renewed. Pair the phones added earlier again: remove this computer on the phone and connect with “Add a phone”.',
addPhone: 'Add a phone', cancel: 'Cancel',
scan: 'On the phone: AITURK Mobile → “Connect to computer” → scan this QR code.',
orCode: 'or type this code on the phone:',
codePending: 'Preparing code sign-in…', codeReady: 'Connecting with the code is ready.',
codeUnavailable: 'Connecting with the code is unavailable right now; scan the QR code.',
sameWifi: 'The phone and the computer must be on the same Wi-Fi network.',
codeUnavailable: 'Connecting with the code is unavailable right now; scan the QR code.', codeRetry: 'Try again',
securityCode: code => `Security code: ${code} (the phone must show the same code)`,
anyNetwork: 'The phone and the computer do not have to be on the same network.', sameWifi: 'The phone and the computer must be on the same Wi-Fi network.',
countdown: (m, s) => `The code expires in ${m}:${s}`, expired: 'The code expired. Choose “Add a phone” again.',
devices: 'Connected phones', noDevices: 'No phone is connected yet.',
added: 'Added', lastSeen: 'Last seen', online: 'connected now', remove: 'Remove',
removed: name => `${name} was removed.`, paired: name => `${name} connected.`,
footer: port => `Port: ${port}`, removeRule: 'Remove the Firewall rule', ruleRemoved: 'The Firewall rule was removed.',
removeRule: 'Remove the Firewall rule', ruleRemoved: 'The Firewall rule was removed.',
renew: 'Renew the relay identity', renewed: 'The relay identity was renewed. Pair the phones again.',
renewConfirm: 'Renewing the relay identity removes every connected phone; they must be paired again. Continue?',
errors: {
remote_mode: 'Phone connection cannot be turned on while the IDE uses a remote gateway.',
vault_unavailable: 'The Windows secure key store is unavailable; phone connection could not be turned on.',
not_enabled: 'Turn phone connection on first.',
no_lan_address: 'This computer does not seem to be on a local network (Wi-Fi or cable).',
no_lan_address: 'This computer does not seem to be on a local network (Wi-Fi or cable) and the internet connection could not be set up either.',
relay_unavailable: 'The internet cannot be reached, try again.',
port_unavailable: 'No free port was found (47321–47340 are in use).',
failed: 'That did not work. Try again.',
},
@@ -66,8 +82,9 @@ const t = TEXT[lang], host = window.mobileHost, $ = id => document.getElementByI
const when = value => new Date(value).toLocaleString(lang === 'tr' ? 'tr-TR' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short' })
document.documentElement.lang = lang; document.title = `AITURK · ${t.title}`
for (const [id, key] of [['t-eyebrow', 'eyebrow'], ['t-title', 'title'], ['t-intro', 'intro'], ['t-remote', 'remote'], ['t-scan', 'scan'],
['t-or-code', 'orCode'], ['t-same-wifi', 'sameWifi'], ['t-devices', 'devices'], ['devices-empty', 'noDevices'], ['firewall-retry', 'retry'],
['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
['t-or-code', 'orCode'], ['t-devices', 'devices'], ['devices-empty', 'noDevices'], ['t-enabled', 'toggle'], ['t-relay-label', 'relayLabel'],
['t-lan-label', 'lanLabel'], ['t-reissued', 'reissued'], ['lan-disallow', 'lanDisallow'], ['relay-renew', 'renew'],
['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['pair-republish', 'codeRetry'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
let current = null, drawnQr = '', busy = false
function notice(text) { $('notice').textContent = text || ''; $('notice').hidden = !text }
@@ -83,10 +100,20 @@ function tick() {
function render(state) {
current = state
$('enabled').checked = state.enabled; $('t-enabled').textContent = state.enabled ? t.enabled : t.disabled
$('enabled').checked = state.enabled
$('remote-box').hidden = !state.remote
const firewall = { needed: t.firewallNeeded, denied: t.firewallDenied, failed: t.firewallFailed }[state.firewall]
$('firewall-box').hidden = !(state.enabled && firewall); $('firewall-text').textContent = firewall || ''
// Status rows say what is REALLY up right now: the relay socket, the LAN socket, the Windows rule as queried.
const relay = state.relay || { status: 'off' }, lan = state.lan || { direct: false, listening: false, port: null, firewall: 'unknown' }
$('status-box').hidden = !state.enabled || state.remote
const relayKind = relay.status === 'failed' && t.relay[relay.code] ? relay.code : relay.status === 'off' ? 'idle' : relay.status
$('relay-status').textContent = t.relay[relayKind]; $('relay-status').dataset.state = relay.status
$('reissued-box').hidden = !(state.enabled && relay.reissued)
const noRule = lan.direct && lan.firewall === 'missing'
$('lan-status').textContent = lan.listening ? t.lanOpen(lan.port) : noRule ? t.lanNoRule : lan.direct ? t.lanIdle
: state.firewall === 'denied' ? t.lanDenied : state.firewall === 'failed' ? t.lanFailed : t.lanOff
$('lan-status').dataset.state = lan.listening ? 'connected' : noRule ? 'failed' : 'off'
$('lan-allow').hidden = lan.direct && !noRule; $('lan-allow').textContent = noRule ? t.allow : t.lanAllow
$('lan-disallow').hidden = !lan.direct
$('pair-section').hidden = !state.enabled || state.remote
$('pair-begin').hidden = Boolean(state.pairing); $('pair-live').hidden = !state.pairing
if (state.pairing) {
@@ -94,6 +121,10 @@ function render(state) {
$('pair-qr').setAttribute('aria-label', t.scan)
$('pair-code').textContent = `${state.pairing.code.slice(0, 4)}-${state.pairing.code.slice(4)}`
$('pair-code-path').textContent = { pending: t.codePending, ready: t.codeReady, unavailable: t.codeUnavailable }[state.pairing.codePath]
// Offered only once every background try is over; while they run the line above says "hazırlanıyor…".
$('pair-republish').hidden = state.pairing.codePath !== 'unavailable'
$('pair-security').textContent = state.pairing.securityCode ? t.securityCode(state.pairing.securityCode) : ''
$('t-same-wifi').textContent = /[?&]r=/.test(state.pairing.qr) ? t.anyNetwork : t.sameWifi
tick()
} else { drawnQr = ''; $('pair-code').textContent = '' }
$('devices-section').hidden = !state.enabled && state.devices.length === 0
@@ -109,9 +140,9 @@ function render(state) {
label.append(name, added, seen); row.append(label, button)
return row
}))
$('footer').hidden = state.port === null
$('footer-info').textContent = state.port === null ? '' : t.footer(state.port)
$('firewall-remove').hidden = state.firewall !== 'ok'
$('footer').hidden = !state.enabled || state.remote
$('relay-renew').hidden = !relay.ready
$('firewall-remove').hidden = lan.firewall !== 'allowed' || state.firewall === 'not-needed'
}
async function run(work) {
@@ -126,7 +157,10 @@ async function refresh() { try { render(await host.call('state')) } catch (error
$('enabled').onchange = () => { const on = $('enabled').checked; void run(async () => { notice(''); render(await host.call(on ? 'enable' : 'disable')) }) }
$('pair-begin').onclick = () => run(async () => { notice(''); render(await host.call('pair.begin')) })
$('pair-cancel').onclick = () => run(async () => { render(await host.call('pair.cancel')) })
$('firewall-retry').onclick = () => run(async () => { notice(''); render(await host.call('firewall.retry')) })
$('pair-republish').onclick = () => run(async () => { render(await host.call('pair.republish')) })
$('lan-allow').onclick = () => run(async () => { notice(''); render(await host.call('lan.allow')) })
$('lan-disallow').onclick = () => run(async () => { notice(''); render(await host.call('lan.disallow')) })
$('relay-renew').onclick = () => { if (window.confirm(t.renewConfirm)) void run(async () => { render(await host.call('relay.renew')); notice(t.renewed) }) }
$('firewall-remove').onclick = () => run(async () => { render(await host.call('firewall.remove')); notice(t.ruleRemoved) })
host.onEvent(event => { if (!event?.state) return; render(event.state); if (event.type === 'paired') notice(t.paired(event.name)) })
setInterval(tick, 1000)
+11
View File
@@ -14,3 +14,14 @@
.device button { flex:none; margin:0 }
footer { margin-top:34px }
button.link { background:none; color:var(--accent); min-height:0; padding:0; margin:0; font-weight:600; text-decoration:underline }
.status-row { display:flex; flex-wrap:wrap; align-items:baseline; justify-content:space-between; gap:4px 12px; border-bottom:1px solid var(--stroke); padding:10px 0 }
.status-row strong { color:var(--text); overflow-wrap:anywhere }
.status-row strong::before { content:''; display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:8px; background:var(--stroke) }
.status-row strong[data-state=connected]::before { background:var(--accent) }
.status-row strong[data-state=failed]::before { background:var(--danger) }
.status-actions { display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin:12px 0 }
.status-actions button { margin:0; min-height:44px }
.security { font-family:ui-monospace,Consolas,monospace; color:var(--text); overflow-wrap:anywhere }
footer { display:flex; flex-wrap:wrap; gap:8px 20px }
footer button.link { min-height:44px }
#pair-republish { display:block; min-height:44px; margin:0 auto; padding:0 12px }
+9 -2
View File
@@ -6,7 +6,12 @@
<label class="switch"><input id="enabled" type="checkbox"><span id="t-enabled"></span></label>
<p id="notice" class="status" role="status" aria-live="polite" hidden></p>
<section id="remote-box" class="warning" hidden><p id="t-remote"></p></section>
<section id="firewall-box" class="warning" hidden><p id="firewall-text"></p><button id="firewall-retry" class="secondary"></button></section>
<section id="status-box" hidden>
<div class="status-row"><span id="t-relay-label"></span><strong id="relay-status" role="status" aria-live="polite"></strong></div>
<div class="status-row"><span id="t-lan-label"></span><strong id="lan-status" role="status" aria-live="polite"></strong></div>
<div class="status-actions"><button id="lan-allow" class="secondary"></button><button id="lan-disallow" class="link" hidden></button></div>
</section>
<section id="reissued-box" class="warning" hidden><p id="t-reissued"></p></section>
<section id="pair-section" hidden>
<button id="pair-begin"></button>
<div id="pair-live" hidden>
@@ -15,7 +20,9 @@
<p id="t-or-code"></p>
<p id="pair-code" class="code" aria-live="off"></p>
<p id="pair-code-path"></p>
<button id="pair-republish" class="link" hidden></button>
<p id="pair-countdown" class="countdown"></p>
<p id="pair-security" class="security"></p>
<p id="t-same-wifi" class="hint"></p>
<button id="pair-cancel" class="secondary"></button>
</div>
@@ -23,5 +30,5 @@
<section id="devices-section" hidden>
<h2 id="t-devices"></h2><p id="devices-empty"></p><div id="devices"></div>
</section>
<footer id="footer" hidden><small id="footer-info"></small> <button id="firewall-remove" class="link"></button></footer>
<footer id="footer" hidden><button id="relay-renew" class="link" hidden></button> <button id="firewall-remove" class="link" hidden></button></footer>
</main><script type="module" src="mobile-host-ui.mjs"></script></body></html>
+8 -1
View File
@@ -20,9 +20,16 @@ export function createMobileWindow({ assets, link, show = true }) {
disable: () => link.disable(),
'pair.begin': () => link.pairBegin(),
'pair.cancel': () => link.pairCancel(),
// Publishes the running pairing's rendezvous record again; the code and the QR stay as they are.
'pair.republish': () => link.pairRepublish(),
devices: () => link.devices(),
'device.revoke': params => link.deviceRevoke(params?.deviceId),
'firewall.retry': () => link.firewallRetry(),
// The only actions that may raise a UAC prompt; both are explicit button presses.
'lan.allow': () => link.lanAllow(),
'lan.disallow': () => link.lanDisallow(),
'firewall.allow': () => link.lanAllow(),
'firewall.retry': () => link.lanAllow(),
'relay.renew': () => link.relayRenew(),
'firewall.remove': () => link.firewallRemove(),
}
const handler = async (event, action, params) => {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+139
View File
@@ -0,0 +1,139 @@
/** Opt-in acceptance against a running local IDE backend. No credentials on disk. */
import https from 'node:https'
import crypto from 'node:crypto'
import type { PeerCertificate } from 'node:tls'
import { expect, it } from 'vitest'
import { WebSocket } from 'ws'
import { createSelfSignedCertificate } from './mobile-lan-cert'
import { createLanProxy } from './mobile-lan-proxy'
it.skipIf(!process.env.AITURK_TEST_BACKEND_PORT)(
'real backend: pinned TLS, screens, cron lifecycle and chat socket',
async () => {
const port = Number(process.env.AITURK_TEST_BACKEND_PORT)
expect(Number.isInteger(port) && port > 0 && port < 65536).toBe(true)
const baseUrl = `http://127.0.0.1:${port}`
const root = await (await fetch(baseUrl)).text()
const session = root.match(/window\.__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"/)?.[1]
expect(Boolean(session)).toBe(true)
const identity = createSelfSignedCertificate()
const checkServerIdentity = (_host: string, cert: PeerCertificate) =>
crypto.createHash('sha256').update(cert.raw).digest('base64url') === identity.fingerprint
? undefined
: new Error('Certificate pin mismatch')
const proxy = createLanProxy({
tls: identity,
version: 'acceptance',
pcName: 'acceptance',
resolveBackend: async profile => {
if (profile !== 'default') throw new Error('Acceptance may only use the default profile')
return { baseUrl, token: session! }
}
})
const proxyPort = await proxy.listen(0, '127.0.0.1')
let deviceToken = ''
let createdId: string | undefined
const call = (path: string, method = 'GET', body?: unknown): Promise<{ status: number; data: any }> =>
new Promise((resolve, reject) => {
const req = https.request(
{
hostname: '127.0.0.1',
port: proxyPort,
path,
method,
ca: identity.cert,
checkServerIdentity,
headers: {
...(deviceToken ? { Authorization: `Bearer ${deviceToken}` } : {}),
'Content-Type': 'application/json'
}
},
res => {
let value = ''
res.on('data', chunk => (value += chunk))
res.on('end', () => {
try {
resolve({ status: res.statusCode!, data: value ? JSON.parse(value) : null })
} catch {
reject(new Error(`Non-JSON response: ${path}`))
}
})
}
)
req.setTimeout(60000, () => req.destroy(new Error(`Timeout: ${path}`)))
req.on('error', reject)
req.end(body === undefined ? undefined : JSON.stringify(body))
})
try {
const pair = await call('/aiturk/pair/claim', 'POST', {
code: proxy.beginPairing().code,
device_name: 'Acceptance ephemeral'
})
expect(pair.status).toBe(200)
deviceToken = pair.data.token
for (const endpoint of [
'health',
'profiles',
'sessions',
'cron/jobs',
'cron/blueprints',
'cron/delivery-targets',
'model/info',
'skills',
'tools/toolsets',
'mcp/servers',
'plugins/kanban/boards'
]) {
const response = await call(`/api/${endpoint}?profile=default`)
expect(response.status, endpoint).toBe(200)
}
const created = await call('/api/cron/jobs?profile=default', 'POST', {
name: `AITURK mobile acceptance ${Date.now()}`,
schedule: '0 0 1 1 *',
prompt: 'Temporary acceptance record; deleted immediately without execution.',
deliver: 'local'
})
expect(created.status).toBe(200)
createdId = created.data.id
expect(typeof createdId).toBe('string')
const route = `/api/cron/jobs/${encodeURIComponent(createdId!)}`
expect((await call(`${route}/pause?profile=default`, 'POST')).status).toBe(200)
expect(
(await call(`${route}?profile=default`, 'PUT', { updates: { name: 'AITURK acceptance updated' } })).status
).toBe(200)
expect((await call(`${route}/resume?profile=default`, 'POST')).status).toBe(200)
await new Promise<void>((resolve, reject) => {
const ws = new WebSocket(`wss://127.0.0.1:${proxyPort}/api/ws?profile=default`, {
ca: identity.cert,
checkServerIdentity,
headers: { Authorization: `Bearer ${deviceToken}` }
})
const timer = setTimeout(() => {
ws.terminate()
reject(new Error('Chat ping timed out'))
}, 15000)
ws.on('open', () => ws.send(JSON.stringify({ jsonrpc: '2.0', id: 123, method: 'gateway.ping', params: {} })))
ws.on('message', raw => {
const message = JSON.parse(String(raw))
if (message.id === 123) {
clearTimeout(timer)
ws.close()
message.error ? reject(new Error('Chat ping rejected')) : resolve()
}
})
ws.on('error', error => {
clearTimeout(timer)
reject(error)
})
})
} finally {
if (createdId) {
const route = `/api/cron/jobs/${encodeURIComponent(createdId)}?profile=default`
expect((await call(route, 'DELETE')).status).toBe(200)
expect((await call(route)).status).toBe(404)
}
await proxy.stop()
}
},
180000
)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,733 @@
/**
* mobile-relay-client.ts
*
* "Telefon bağlantısı" through the turkservis.online tunnel (contract:
* workspace/aiturk-mobil-tunel-spec.md, v3). The IDE opens ONE outbound
* WebSocket to the gateway. For every phone stream the gateway announces
* (`open`), this client opens a PLAIN TCP connection to this IDE's own TLS
* proxy on loopback (electron/mobile-lan-proxy.ts) and copies bytes both ways.
*
* It never terminates TLS and never looks at a byte: the TLS session is
* between the phone (which pins the proxy's certificate) and the proxy. So the
* gateway — and this file — only ever carry ciphertext, and every rule (device
* token, forbidden list, root 404, WebSocket path list) stays in the proxy.
*
* Security contract (do not relax):
* - `hostToken` (`tsmr_…`) lives only in the injected credential store (the
* controller keeps it in the safeStorage vault). It is sent in the
* `Authorization` header of the host WebSocket and nowhere else: not in a
* log line, a status object or an error message.
* - Log lines are status changes only.
* - Errors are fixed codes.
*
* Everything that touches the outside is injected (WebSocket connector, fetch,
* local TCP dial, timers, credential store) so the tests run against a fake
* relay on 127.0.0.1 and a fake clock.
*/
export const MOBILE_RELAY_ORIGIN = 'https://turkservis.online'
export const MOBILE_RELAY_PATH = '/v1/aiturk/mobile-relay'
export const MOBILE_RELAY_FETCH_TIMEOUT_MS = 20_000
/** Largest payload of one binary frame, either direction (contract §1). */
export const RELAY_CHUNK_BYTES = 64 * 1024
/** Stream id prefix of a binary frame: big-endian uint32. */
export const RELAY_FRAME_HEADER_BYTES = 4
/** Pending bytes allowed per direction of one stream (contract §1 / §2). */
export const RELAY_BUFFER_LIMIT_BYTES = 1024 * 1024
/** Reconnect delays: 2 s, then growing to 30 s. */
export const RELAY_BACKOFF_MS: readonly number[] = [2000, 4000, 8000, 16_000, 30_000]
/** The gateway pings every 20 s and drops a silent host after 60 s; we give up a little later. */
export const RELAY_SILENCE_MS = 75_000
/** Concurrent phone streams (the gateway allows 16 per relay). */
export const RELAY_MAX_STREAMS = 16
/** Upper bound for one frame on the host socket; a data frame is at most 64 KiB + 4. */
export const RELAY_MAX_FRAME_BYTES = 1024 * 1024
const BUFFER_RESUME_BYTES = 256 * 1024
const BUFFER_POLL_MS = 25
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
const HOST_TOKEN_RE = /^tsmr_[A-Za-z0-9_-]{43}$/
export const relayHostUrl = (relayId: string) => `wss://turkservis.online${MOBILE_RELAY_PATH}/host/${relayId}`
/** The `r` of the QR and the `relay` of the rendezvous record: a lower-case uuid, nothing else. */
export function isRelayId(value: unknown): value is string {
return typeof value === 'string' && UUID_RE.test(value)
}
export interface RelayCredentials {
relayId: string
hostToken: string
}
export function isRelayCredentials(value: unknown): value is RelayCredentials {
const row = value as null | RelayCredentials
return Boolean(row) && isRelayId(row.relayId) && typeof row.hostToken === 'string' && HOST_TOKEN_RE.test(row.hostToken)
}
/** Validates the `POST /sessions` answer: `{relayId, hostToken}` and nothing we would follow. */
export function parseRelaySession(data: unknown): null | RelayCredentials {
const row = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>
const candidate = { relayId: typeof row.relayId === 'string' ? row.relayId.toLowerCase() : row.relayId, hostToken: row.hostToken }
return isRelayCredentials(candidate) ? { relayId: candidate.relayId, hostToken: candidate.hostToken } : null
}
export interface RelayFetchResponse {
status: number
json: () => Promise<unknown>
}
export type RelayFetch = (url: string, init: Record<string, unknown>) => Promise<RelayFetchResponse>
export type RelayFailureCode =
| 'register_failed'
| 'rate_limited'
| 'vault_unavailable'
| 'connect_failed'
| 'token_rejected'
| 'replaced'
| 'busy'
| 'timeout'
/** Wait before the single second try of a request whose first try never got an answer. */
export const MOBILE_NETWORK_RETRY_DELAY_MS = 500
/** An aborted / timed-out request already used its whole budget: it is not tried again. */
export function isTimeoutError(error: unknown): boolean {
const name = (error as null | { name?: unknown })?.name
return name === 'TimeoutError' || name === 'AbortError'
}
export interface NetworkRetryOptions {
/** Injected wait (tests: fake clock). Default: a real timer. */
sleep?: (ms: number) => Promise<void>
retryDelayMs?: number
}
const realSleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
/**
* `POST /sessions`; a fixed failure code instead of any upstream text.
*
* A process that sat idle for a long time can fail its first request on a dead
* pooled keep-alive connection. So a THROWN network error (no HTTP answer at
* all) is tried once more after ~0.5 s. Any HTTP answer is final: no retry.
*/
export async function registerRelaySession(
fetcher: RelayFetch,
options: NetworkRetryOptions = {}
): Promise<RelayCredentials | { error: RelayFailureCode }> {
const sleep = options.sleep ?? realSleep
for (let attempt = 1; ; attempt++) {
let response: RelayFetchResponse
try {
response = await fetcher(`${MOBILE_RELAY_ORIGIN}${MOBILE_RELAY_PATH}/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: '{}',
redirect: 'error',
signal: AbortSignal.timeout(MOBILE_RELAY_FETCH_TIMEOUT_MS)
})
} catch (error) {
if (attempt >= 2 || isTimeoutError(error)) {
return { error: 'register_failed' }
}
await sleep(options.retryDelayMs ?? MOBILE_NETWORK_RETRY_DELAY_MS)
continue
}
try {
if (response.status !== 201) {
return { error: response.status === 429 ? 'rate_limited' : 'register_failed' }
}
return parseRelaySession(await response.json()) ?? { error: 'register_failed' }
} catch {
return { error: 'register_failed' }
}
}
}
// ── What the client needs from the outside ──────────────────────────────────
/** Host WebSocket (the `ws` package fits: it can send request headers). */
export interface RelaySocket {
on: (event: string, listener: (...args: any[]) => void) => unknown
/** A string is a text frame, a Buffer a binary frame. */
send: (data: Buffer | string) => unknown
close: (code?: number) => unknown
terminate?: () => unknown
/** Stop / continue reading from the gateway (back-pressure towards a slow local connection). */
pause?: () => unknown
resume?: () => unknown
readonly bufferedAmount?: number
}
export type RelayConnect = (url: string, headers: Record<string, string>) => RelaySocket
export interface RelayWebSocketCtor {
new (url: string, options?: Record<string, unknown>): any
}
/** Plain TCP connection to the local proxy (`net.Socket` fits). */
export interface RelayLocalSocket {
on: (event: string, listener: (...args: any[]) => void) => unknown
write: (data: Buffer) => boolean
destroy: () => unknown
pause: () => unknown
resume: () => unknown
readonly writableLength?: number
}
/** null = the proxy is not listening right now. */
export type RelayDial = () => null | RelayLocalSocket
/** One binary frame: stream id (big-endian uint32) + payload. */
export function encodeRelayFrame(id: number, payload: Buffer): Buffer {
const frame = Buffer.allocUnsafe(RELAY_FRAME_HEADER_BYTES + payload.length)
frame.writeUInt32BE(id, 0)
payload.copy(frame, RELAY_FRAME_HEADER_BYTES)
return frame
}
/** Pieces of at most `size` bytes, in order, without copying. */
export function splitRelayChunks(chunk: Buffer, size = RELAY_CHUNK_BYTES): Buffer[] {
const parts: Buffer[] = []
for (let offset = 0; offset < chunk.length; offset += size) {
parts.push(chunk.subarray(offset, offset + size))
}
return parts
}
const validStreamId = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 0xffffffff
// ── The client ──────────────────────────────────────────────────────────────
export type RelayStatus = 'off' | 'connecting' | 'connected' | 'failed'
export interface RelayState {
status: RelayStatus
/** Fixed code while `failed` (a retry is always scheduled), otherwise null. */
code: null | RelayFailureCode
}
export interface RelayCredentialStore {
/** null when there is no registration yet. Throwing = the vault is unusable. */
load: () => Promise<null | RelayCredentials>
/** `reissued` = this replaces a registration (rejected by the gateway, or renewed by the user). */
save: (credentials: RelayCredentials, info: { reissued: boolean }) => Promise<void>
clear: () => Promise<void>
}
export interface RelayClientDeps {
connect: RelayConnect
fetch: RelayFetch
dial: RelayDial
credentials: RelayCredentialStore
setTimer?: (fn: () => void, ms: number) => unknown
clearTimer?: (handle: unknown) => void
/** Status changes only. */
log?: (line: string) => void
onState?: (state: RelayState) => void
}
interface Stream {
local: RelayLocalSocket
paused: boolean
}
export function createRelayClient(deps: RelayClientDeps) {
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as NodeJS.Timeout))
let state: RelayState = { status: 'off', code: null }
let wanted = false
let generation = 0
let attempt = 0
let socket: null | RelaySocket = null
let credentials: null | RelayCredentials = null
/** The gateway rejected the stored registration; the next one replaces it. */
let rejected = false
let registering: null | Promise<RelayCredentials | { error: RelayFailureCode }> = null
let retryTimer: unknown = null
let silenceTimer: unknown = null
const streams = new Map<number, Stream>()
/** Same (injectable) clock as every other wait of this client. */
const sleep = (ms: number) => new Promise<void>(resolve => void setTimer(resolve, ms))
function setState(status: RelayStatus, code: null | RelayFailureCode = null) {
if (state.status === status && state.code === code) {
return
}
state = { status, code }
deps.log?.(`relay ${status}${code ? `:${code}` : ''}`)
try {
deps.onState?.(state)
} catch {
// a broken listener must not break the relay
}
}
function clearTimers() {
if (retryTimer !== null) {
clearTimer(retryTimer)
retryTimer = null
}
if (silenceTimer !== null) {
clearTimer(silenceTimer)
silenceTimer = null
}
}
/** Stored registration, or a new one. One request at a time. */
function register(): Promise<RelayCredentials | { error: RelayFailureCode }> {
if (credentials) {
return Promise.resolve(credentials)
}
registering ??= (async (): Promise<RelayCredentials | { error: RelayFailureCode }> => {
let stored: null | RelayCredentials
try {
stored = await deps.credentials.load()
} catch {
return { error: 'vault_unavailable' }
}
if (isRelayCredentials(stored)) {
credentials = stored
return stored
}
const fresh = await registerRelaySession(deps.fetch, { sleep })
if ('error' in fresh) {
return fresh
}
try {
await deps.credentials.save(fresh, { reissued: rejected })
} catch {
return { error: 'vault_unavailable' }
}
rejected = false
credentials = fresh
return fresh
})().finally(() => {
registering = null
})
return registering
}
function dropStreams() {
for (const stream of streams.values()) {
stream.local.destroy()
}
streams.clear()
}
function retry(code: RelayFailureCode, delay?: number) {
const wait = delay ?? RELAY_BACKOFF_MS[Math.min(attempt, RELAY_BACKOFF_MS.length - 1)]
attempt++
setState('failed', code)
retryTimer = setTimer(() => {
retryTimer = null
void connect()
}, wait)
}
async function connect() {
if (!wanted || socket) {
return
}
const mine = ++generation
clearTimers()
setState('connecting')
const current = await register()
if (!wanted || mine !== generation) {
return
}
if ('error' in current) {
retry(current.error)
return
}
let ws: RelaySocket
try {
ws = deps.connect(relayHostUrl(current.relayId), { Authorization: `Bearer ${current.hostToken}` })
} catch {
retry('connect_failed')
return
}
socket = ws
const send = (data: Buffer | string) => {
if (socket === ws) {
try {
ws.send(data)
} catch {
// the close event follows
}
}
}
const control = (value: Record<string, unknown>) => send(JSON.stringify(value))
const watch = () => {
if (silenceTimer !== null) {
clearTimer(silenceTimer)
}
silenceTimer = setTimer(() => {
silenceTimer = null
if (socket === ws) {
try {
;(ws.terminate ?? ws.close).call(ws)
} catch {
// already gone
}
down(1006, 'timeout')
}
}, RELAY_SILENCE_MS)
}
const down = (code: number, reason?: RelayFailureCode) => {
if (socket !== ws) {
return
}
socket = null
clearTimers()
dropStreams()
if (!wanted) {
setState('off')
return
}
// 4401: the token was refused. 4404 / HTTP 404: the gateway no longer knows this relay
// (unused for 30 days). Either way the registration is dead: take a new one.
if (code === 4401 || code === 4404) {
credentials = null
rejected = true
void deps.credentials
.clear()
.catch(() => undefined)
.then(() => {
if (wanted && socket === null && retryTimer === null && mine === generation) {
retry('token_rejected')
}
})
return
}
if (code === 4409) {
// Another IDE instance holds this registration: wait 30 s instead of fighting it.
retry('replaced', RELAY_BACKOFF_MS[RELAY_BACKOFF_MS.length - 1])
return
}
// 4429: the relay service is full; the normal growing delay applies.
retry(reason ?? (code === 4429 ? 'busy' : 'connect_failed'))
}
// Gateway → local back-pressure: while any local connection is behind, stop reading the host socket.
const stalled = new Set<Stream>()
const unstall = (stream: Stream) => {
if (stalled.delete(stream) && stalled.size === 0 && socket === ws) {
ws.resume?.()
}
}
/** Ends one stream on both sides. `tell` = the gateway does not know yet. */
const endStream = (id: number, stream: Stream, code: number, tell: boolean) => {
if (streams.get(id) !== stream) {
return
}
streams.delete(id)
unstall(stream)
stream.local.destroy()
if (tell) {
control({ t: 'close', id, code })
}
}
const openStream = (id: number) => {
if (streams.has(id)) {
return
}
if (streams.size >= RELAY_MAX_STREAMS) {
return control({ t: 'close', id, code: 4429 })
}
let local: null | RelayLocalSocket
try {
local = deps.dial()
} catch {
local = null
}
if (!local) {
return control({ t: 'close', id, code: 4503 })
}
const stream: Stream = { local, paused: false }
let failed = false
const live = () => streams.get(id) === stream
// Local → gateway back-pressure: stop reading the proxy while the host socket is behind.
const drain = () => {
if (!live() || socket !== ws) {
return
}
if ((ws.bufferedAmount ?? 0) > BUFFER_RESUME_BYTES) {
setTimer(drain, BUFFER_POLL_MS)
return
}
stream.paused = false
local.resume()
}
streams.set(id, stream)
local.on('connect', () => {
if (live()) {
control({ t: 'opened', id })
}
})
local.on('data', (chunk: Buffer) => {
if (!live()) {
return
}
// Bytes are forwarded exactly as read: no parsing, no TLS here.
for (const part of splitRelayChunks(chunk)) {
send(encodeRelayFrame(id, part))
}
if (!stream.paused && (ws.bufferedAmount ?? 0) > RELAY_BUFFER_LIMIT_BYTES) {
stream.paused = true
local.pause()
setTimer(drain, BUFFER_POLL_MS)
}
})
local.on('drain', () => unstall(stream))
local.on('error', () => {
failed = true
})
local.on('close', () => endStream(id, stream, failed ? 1011 : 1000, true))
}
ws.on('open', () => {
if (socket === ws) {
attempt = 0
setState('connected')
watch()
}
})
ws.on('close', (code: number) => down(Number(code) || 1006))
ws.on('error', () => down(1006))
// A refusal at the HTTP level (before the WebSocket exists) means the same as the close codes.
ws.on('unexpected-response', (req: { destroy?: () => void }, res: { statusCode?: number; resume?: () => void }) => {
const status = Number(res?.statusCode)
res?.resume?.()
req?.destroy?.()
down(status === 401 || status === 403 ? 4401 : status === 404 ? 4404 : status === 429 ? 4429 : 1006)
})
ws.on('message', (data: unknown, isBinary?: boolean) => {
if (socket !== ws) {
return
}
watch()
if (isBinary) {
const frame = Buffer.isBuffer(data) ? data : Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer)
if (frame.length <= RELAY_FRAME_HEADER_BYTES) {
return
}
const id = frame.readUInt32BE(0)
const stream = streams.get(id)
// Late or unknown ids are ignored.
if (!stream) {
return
}
stream.local.write(frame.subarray(RELAY_FRAME_HEADER_BYTES))
const pending = stream.local.writableLength ?? 0
// Gateway → local: a proxy that does not read must not make this process hold the data.
// First stop reading the gateway; if the backlog still reaches 1 MiB, give the stream up.
if (pending > RELAY_BUFFER_LIMIT_BYTES) {
endStream(id, stream, 4408, true)
} else if (pending > BUFFER_RESUME_BYTES && ws.pause && !stalled.has(stream)) {
stalled.add(stream)
ws.pause()
}
return
}
let message: any
try {
message = JSON.parse(typeof data === 'string' ? data : Buffer.isBuffer(data) ? data.toString('utf8') : String(data))
} catch {
return
}
if (!message || typeof message !== 'object') {
return
}
if (message.t === 'ping') {
return control({ t: 'pong' })
}
if (!validStreamId(message.id)) {
return
}
if (message.t === 'open') {
return openStream(message.id)
}
if (message.t === 'close') {
const stream = streams.get(message.id)
if (stream) {
endStream(message.id, stream, 1000, false)
}
}
})
}
const api = {
state: (): RelayState => state,
/** Open phone streams right now. */
streams: (): number => streams.size,
/**
* Makes sure a registration exists (stored or new) and returns its id; null
* when the gateway or the vault is unavailable. Does not open the socket.
*/
async ensureRegistered(): Promise<null | string> {
const current = await register()
return 'error' in current ? null : current.relayId
},
/**
* "Röle kimliğini yenile": take a NEW registration. The old one is replaced
* only once the new one is stored; false = nothing changed.
*/
async renew(): Promise<boolean> {
const fresh = await registerRelaySession(deps.fetch, { sleep })
if ('error' in fresh) {
return false
}
try {
await deps.credentials.save(fresh, { reissued: true })
} catch {
return false
}
credentials = fresh
rejected = false
if (wanted) {
// Drop the socket that was authenticated with the old token and come back with the new one.
api.stop()
api.start()
}
return true
},
start() {
if (wanted) {
return
}
wanted = true
attempt = 0
void connect()
},
stop() {
wanted = false
generation++
clearTimers()
const ws = socket
socket = null
dropStreams()
if (ws) {
try {
ws.close(1000)
} catch {
// already gone
}
}
setState('off')
}
}
return api
}
export type RelayClient = ReturnType<typeof createRelayClient>
@@ -173,3 +173,21 @@ test('reset rejects with a typed error and never enters either backend', async (
assert.equal(options.connectRemote.mock.calls.length, 0)
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
})
test('a sibling runtime completion re-resolves the primary profile without re-running the stale installer', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const installed = { kind: 'python', activeRoot: bootstrapBackend.activeRoot, args: ['--profile', 'fusion-frontier'] }
const prepareLocalBackend = vi.fn().mockResolvedValueOnce(bootstrapBackend).mockResolvedValue(installed)
const options = startupOptions({ waitForDecision: gate.wait, prepareLocalBackend })
const pending = runPrimaryBackendStartup(options)
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
gate.runtimeAvailable(bootstrapBackend.activeRoot)
await pending
assert.deepEqual(options.ensureLocalRuntime.mock.calls, [[installed]])
})
test('a runtime that disappeared after readiness cannot silently trigger another installation', async () => {
const options = startupOptions({ waitForDecision: async () => 'runtime-ready' as const })
await assert.rejects(runPrimaryBackendStartup(options), /no longer available/)
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
})
@@ -109,5 +109,13 @@ export async function runPrimaryBackendStartup<Backend, RuntimeBackend, Remote,
throw new FirstRunSetupResetError()
}
if (decision === 'runtime-ready') {
const current = await prepareLocalBackend()
if ((current as { kind?: string }).kind === 'bootstrap-needed') {
throw new Error('The completed local runtime is no longer available. Retry setup.')
}
return { kind: 'local', backend: await ensureLocalRuntime(current) }
}
return { kind: 'local', backend: await ensureLocalRuntime(backend) }
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "aiturk-ide",
"productName": "AITURK IDE",
"private": true,
"version": "1.0.0-beta.15",
"version": "1.0.0-beta.19",
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
"author": "AITURK / TurkServis",
"repository": {
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: [
'electron/mobile-*.test.ts',
'electron/first-run-setup*.test.ts',
'electron/primary-backend-startup.test.ts'
]
}
})
+29
View File
@@ -1470,6 +1470,34 @@ def _wait_for_oneshot_background_completions(cli) -> None:
)
def _emit_approval_required(session_id) -> None:
"""Report approvals a one-shot run could not put in front of a human.
One ``approval_required: <json>`` line per blocked request on stderr,
next to ``session_id:``, so the process that launched this run can tell
"blocked, needs a human" from "still working" without parsing prose.
Nothing was applied for these requests; resuming the same session in an
interactive surface is the way to get them approved.
"""
try:
from tools.approval import drain_human_approval_required
records = drain_human_approval_required()
except Exception:
return
for record in records:
try:
if not record.get("session_id"):
record["session_id"] = session_id or ""
record["resume"] = "interactive"
print(
"approval_required: "
+ json.dumps(record, ensure_ascii=True, sort_keys=True),
file=sys.stderr,
)
except Exception:
continue
def _finalize_single_query(cli) -> None:
"""Close one-shot CLI resources before releasing the active session lease."""
try:
@@ -22360,6 +22388,7 @@ def main(
# Session ID goes to stderr so piped stdout is clean.
print(f"\nsession_id: {cli.session_id}", file=sys.stderr)
_emit_approval_required(cli.session_id)
# Ensure proper exit code for automation wrappers.
#
+1 -1
View File
@@ -65,7 +65,7 @@
},
"apps/desktop": {
"name": "aiturk-ide",
"version": "1.0.0-beta.15",
"version": "1.0.0-beta.19",
"license": "MIT",
"dependencies": {
"@assistant-ui/core": "0.2.23",
+1
View File
@@ -272,6 +272,7 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
"HERMES_REAL_HOME",
"TERMINAL_HOME_MODE",
"HERMES_INTERACTIVE",
"HERMES_SINGLE_QUERY_SESSION",
"HERMES_QUIET",
"HERMES_TOOL_PROGRESS",
"HERMES_TOOL_PROGRESS_MODE",
@@ -0,0 +1,578 @@
"""Protected instruction-file gate when no human can be reached.
A one-shot run (``chat -q`` / ``--query-file``) installs the CLI approval
callback but never starts the prompt UI. The gate used to treat the
registered callback as a reachable human and wait out ``approvals.timeout``
on a panel nobody could see. These tests pin the fixed contract:
* no reachable human -> BLOCKED at once, nothing written, the outcome is
recorded for the launching process;
* the gate itself is unchanged: still asks a real human every time, still
ignores yolo and ``approvals.single_query_mode``;
* an approval that arrives after the calling execute_code script stopped
waiting is not applied.
"""
import json
import time
import pytest
@pytest.fixture(autouse=True)
def _gate_on(monkeypatch):
import tools.approval as A
import tools.file_tools as ft
monkeypatch.setattr(ft, "_protected_instruction_config", lambda: (True, []))
monkeypatch.delenv("HERMES_SINGLE_QUERY_SESSION", raising=False)
A.drain_human_approval_required()
A.set_approval_reply_deadline(None)
yield
A.drain_human_approval_required()
A.set_approval_reply_deadline(None)
@pytest.fixture
def approvals():
"""CLI approval callback that records calls and gives a scripted answer."""
from tools.terminal_tool import set_approval_callback
state = {"calls": [], "answer": "deny"}
def cb(command, description, **kwargs):
state["calls"].append({"command": command, **kwargs})
answer = state["answer"]
if callable(answer):
return answer()
return answer
set_approval_callback(cb)
yield state
set_approval_callback(None)
@pytest.fixture
def one_shot(monkeypatch):
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
def _write(path, content="injected"):
from tools.file_tools import write_file_tool
return json.loads(write_file_tool(str(path), content))
def _records():
import tools.approval as A
return A.drain_human_approval_required()
# ---- one-shot run: callback registered, nobody to answer ------------------
class TestOneShotRun:
def test_blocks_at_once_without_prompting(self, tmp_path, approvals, one_shot):
target = tmp_path / "AGENTS.md"
target.write_text("rules\n", encoding="utf-8")
approvals["answer"] = "once" # would be honored if anyone were asked
started = time.monotonic()
res = _write(target)
elapsed = time.monotonic() - started
assert res.get("error") and "BLOCKED" in res["error"]
assert "APPROVAL REQUIRED" in res["error"]
assert target.read_text(encoding="utf-8") == "rules\n"
assert approvals["calls"] == [], "prompted on a surface nobody can see"
assert elapsed < 5
def test_outcome_is_recorded_for_the_launcher(self, tmp_path, approvals, one_shot):
import tools.approval as A
target = tmp_path / "CLAUDE.md"
sid = A._approval_session_id.set("sess-123")
tcid = A._approval_tool_call_id.set("call-9")
try:
_write(target)
finally:
A._approval_tool_call_id.reset(tcid)
A._approval_session_id.reset(sid)
records = _records()
assert len(records) == 1
rec = records[0]
assert rec["kind"] == "protected_instruction_file"
assert rec["reason"] == "no_interactive_user"
assert rec["applied"] is False
assert rec["session_id"] == "sess-123"
assert rec["tool_call_id"] == "call-9"
assert rec["request_id"]
assert any("CLAUDE.md" in t for t in rec["targets"])
assert _records() == [], "drain must clear"
def test_yolo_does_not_open_the_gate(self, tmp_path, approvals, one_shot, monkeypatch):
import tools.approval as A
monkeypatch.setattr(A, "_YOLO_MODE_FROZEN", True)
target = tmp_path / "AGENTS.md"
res = _write(target)
assert res.get("error") and "BLOCKED" in res["error"]
assert not target.exists()
def test_single_query_mode_approve_does_not_open_the_gate(
self, tmp_path, approvals, one_shot, monkeypatch
):
import tools.approval as A
real_cfg_get = A.cfg_get
def fake_cfg_get(config, *keys, default=None):
if keys == ("approvals", "single_query_mode"):
return "approve"
return real_cfg_get(config, *keys, default=default)
monkeypatch.setattr(A, "cfg_get", fake_cfg_get)
target = tmp_path / "SOUL.md"
res = _write(target)
assert res.get("error") and "BLOCKED" in res["error"]
assert not target.exists()
def test_regular_file_still_writes(self, tmp_path, approvals, one_shot):
target = tmp_path / "notes.md"
res = _write(target, "hello")
assert not res.get("error"), res
assert target.read_text(encoding="utf-8") == "hello"
assert _records() == []
def test_multi_file_patch_applies_nothing(self, tmp_path, approvals, one_shot):
from tools.file_tools import patch_tool
agents = tmp_path / "AGENTS.md"
agents.write_text("rules\n", encoding="utf-8")
plain = tmp_path / "plain.txt"
plain.write_text("hello\n", encoding="utf-8")
patch = (
"*** Begin Patch\n"
f"*** Update File: {plain}\n"
"@@\n"
"-hello\n"
"+world\n"
f"*** Update File: {agents}\n"
"@@\n"
"-rules\n"
"+injected\n"
"*** End Patch"
)
res = json.loads(patch_tool(mode="patch", patch=patch))
assert res.get("error") and "BLOCKED" in res["error"]
assert plain.read_text(encoding="utf-8") == "hello\n"
assert agents.read_text(encoding="utf-8") == "rules\n"
assert approvals["calls"] == []
def test_retry_in_same_run_blocks_again(self, tmp_path, approvals, one_shot):
target = tmp_path / "AGENTS.md"
_write(target)
res = _write(target)
assert res.get("error") and "BLOCKED" in res["error"]
assert not target.exists()
assert len(_records()) == 2
# ---- no channel at all ----------------------------------------------------
def test_no_channel_fails_closed_and_is_recorded(tmp_path):
target = tmp_path / "AGENTS.md"
res = _write(target)
assert res.get("error") and "BLOCKED" in res["error"]
assert not target.exists()
records = _records()
assert [r["reason"] for r in records] == ["no_approval_channel"]
def test_undeliverable_gateway_request_is_recorded(tmp_path):
import tools.approval as A
session_key = "protected-unattended-undelivered"
token = A.set_current_session_key(session_key)
try:
def notify(approval_data):
raise RuntimeError("client went away")
A.register_gateway_notify(session_key, notify)
try:
target = tmp_path / "AGENTS.md"
res = _write(target)
finally:
A.unregister_gateway_notify(session_key)
finally:
A.reset_current_session_key(token)
assert res.get("error") and "BLOCKED" in res["error"]
assert not target.exists()
assert [r["reason"] for r in _records()] == ["approval_not_delivered"]
# ---- a reachable human: the gate behaves exactly as before -----------------
class TestReachableHuman:
def test_allow_writes(self, tmp_path, approvals):
target = tmp_path / "AGENTS.md"
approvals["answer"] = "once"
res = _write(target, "approved")
assert not res.get("error"), res
assert target.read_text(encoding="utf-8") == "approved"
assert len(approvals["calls"]) == 1
assert _records() == []
@pytest.mark.parametrize("answer", ["deny", "timeout"])
def test_deny_and_silence_block(self, tmp_path, approvals, answer):
target = tmp_path / "AGENTS.md"
approvals["answer"] = answer
res = _write(target)
assert res.get("error") and "BLOCKED" in res["error"]
assert not target.exists()
assert len(approvals["calls"]) == 1
# A human was asked and did not consent: not an "unattended" outcome.
assert _records() == []
def test_prompt_that_dies_blocks(self, tmp_path, approvals):
"""Cancel / lost surface: the prompt raising is not consent."""
def boom():
raise RuntimeError("prompt surface lost")
target = tmp_path / "AGENTS.md"
approvals["answer"] = boom
res = _write(target)
assert res.get("error") and "BLOCKED" in res["error"]
assert not target.exists()
# ---- approval that arrives after the caller stopped waiting ----------------
class TestLateApproval:
def test_late_cli_approval_is_not_applied(self, tmp_path, approvals):
import tools.approval as A
target = tmp_path / "AGENTS.md"
target.write_text("rules\n", encoding="utf-8")
def answer_after_deadline():
# The human says yes, but only once the execute_code script that
# asked for this write has already timed out.
A.set_approval_reply_deadline(time.monotonic() - 1)
return "once"
A.set_approval_reply_deadline(time.monotonic() + 300)
approvals["answer"] = answer_after_deadline
res = _write(target)
assert res.get("error") and "BLOCKED" in res["error"]
assert "NOT applied" in res["error"]
assert target.read_text(encoding="utf-8") == "rules\n"
def test_late_approval_does_not_carry_to_the_next_request(self, tmp_path, approvals):
import tools.approval as A
target = tmp_path / "AGENTS.md"
A.set_approval_reply_deadline(time.monotonic() - 1)
approvals["answer"] = "once"
assert "BLOCKED" in _write(target).get("error", "")
assert not target.exists()
# A fresh call (new deadline) asks again; the old yes is not reused.
A.set_approval_reply_deadline(time.monotonic() + 300)
approvals["answer"] = "deny"
assert "BLOCKED" in _write(target).get("error", "")
assert not target.exists()
assert len(approvals["calls"]) == 2
def test_in_time_approval_is_applied(self, tmp_path, approvals):
import tools.approval as A
target = tmp_path / "AGENTS.md"
A.set_approval_reply_deadline(time.monotonic() + 300)
approvals["answer"] = "once"
res = _write(target, "approved")
assert not res.get("error"), res
assert target.read_text(encoding="utf-8") == "approved"
def test_late_gateway_approval_is_not_applied(self, tmp_path):
import tools.approval as A
session_key = "protected-unattended-late"
token = A.set_current_session_key(session_key)
try:
def notify(approval_data):
A.resolve_gateway_approval(session_key, "once")
A.register_gateway_notify(session_key, notify)
try:
A.set_approval_reply_deadline(time.monotonic() - 1)
target = tmp_path / "AGENTS.md"
res = _write(target)
finally:
A.unregister_gateway_notify(session_key)
finally:
A.reset_current_session_key(token)
assert res.get("error") and "NOT applied" in res["error"]
assert not target.exists()
def test_no_deadline_outside_execute_code(self):
import tools.approval as A
assert A.approval_reply_window_remaining() is None
assert A.approval_arrived_too_late() is False
def test_rpc_dispatch_binds_and_restores_the_deadline(self):
import tools.approval as A
import tools.code_execution_tool as cet
start = time.monotonic()
previous = cet._bind_rpc_reply_deadline(start)
try:
remaining = A.approval_reply_window_remaining()
assert remaining is not None
assert 0 < remaining <= cet._RPC_REPLY_TIMEOUT_SECONDS
finally:
cet._restore_rpc_reply_deadline(previous)
assert A.approval_reply_window_remaining() is None
def test_deadline_is_per_thread(self):
import threading
import tools.approval as A
A.set_approval_reply_deadline(time.monotonic() - 1)
seen = {}
def other():
seen["late"] = A.approval_arrived_too_late()
worker = threading.Thread(target=other)
worker.start()
worker.join()
assert seen["late"] is False
assert A.approval_arrived_too_late() is True
# ---- what the launching process sees ---------------------------------------
def test_recorded_outcomes_are_bounded():
import tools.approval as A
for i in range(A._HUMAN_APPROVAL_REQUIRED_MAX + 20):
A.note_human_approval_required("protected_instruction_file", [f"f{i}"], "x")
records = A.drain_human_approval_required()
assert len(records) == A._HUMAN_APPROVAL_REQUIRED_MAX
assert records[-1]["targets"] == [f"f{A._HUMAN_APPROVAL_REQUIRED_MAX + 19}"]
def test_one_shot_exit_reports_approval_required_on_stderr(capsys):
import cli as cli_module
import tools.approval as A
A.note_human_approval_required(
"protected_instruction_file", ["AGENTS.md"], "no_interactive_user"
)
cli_module._emit_approval_required("sess-42")
err = capsys.readouterr().err
lines = [l for l in err.splitlines() if l.startswith("approval_required: ")]
assert len(lines) == 1
payload = json.loads(lines[0].split(": ", 1)[1])
assert payload["session_id"] == "sess-42"
assert payload["applied"] is False
assert payload["reason"] == "no_interactive_user"
assert payload["resume"] == "interactive"
assert payload["targets"] == ["AGENTS.md"]
cli_module._emit_approval_required("sess-42")
assert "approval_required" not in capsys.readouterr().err
# ---- execute_code RPC: the dispatcher really binds the window ---------------
def _drive_rpc_once(dispatch, stop_event=None):
"""Send one tool call through the real RPC server loop; return its reply."""
import socket
import threading
import tools.code_execution_tool as cet
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("127.0.0.1", 0))
server.listen(1)
stop_event = stop_event or threading.Event()
worker = threading.Thread(
target=cet._rpc_server_loop,
args=(server, "task", [], [0], 5, frozenset({"patch"}), stop_event, "tok", dispatch),
daemon=True,
)
worker.start()
client = socket.create_connection(server.getsockname(), timeout=10)
try:
client.sendall(
(json.dumps({"tool": "patch", "args": {}, "token": "tok"}) + "\n").encode()
)
buf = b""
while not buf.endswith(b"\n"):
chunk = client.recv(65536)
if not chunk:
break
buf += chunk
finally:
client.close()
stop_event.set()
worker.join(timeout=10)
server.close()
return buf.decode().strip()
def test_rpc_server_loop_binds_reply_window_around_dispatch():
import tools.approval as A
import tools.code_execution_tool as cet
seen = {}
def dispatch(tool_name, tool_args):
seen["remaining"] = A.approval_reply_window_remaining()
seen["late"] = A.approval_arrived_too_late()
return json.dumps({"ok": True})
reply = _drive_rpc_once(dispatch)
assert json.loads(reply) == {"ok": True}
assert seen["late"] is False
assert 0 < seen["remaining"] <= cet._RPC_REPLY_TIMEOUT_SECONDS
def test_rpc_approval_after_script_ended_is_late():
"""Script killed by its own run timeout while the prompt is still open."""
import threading
import tools.approval as A
stop_event = threading.Event()
seen = {}
def dispatch(tool_name, tool_args):
seen["before"] = A.approval_arrived_too_late()
stop_event.set() # the script ended while a human was deciding
seen["after"] = A.approval_arrived_too_late()
return json.dumps({"ok": True})
_drive_rpc_once(dispatch, stop_event)
assert seen == {"before": False, "after": True}
def test_protected_write_through_rpc_is_dropped_when_script_ended(tmp_path, approvals):
"""End to end through the RPC loop: late yes -> BLOCKED, file untouched."""
import threading
from tools.file_tools import write_file_tool
from tools.terminal_tool import _get_approval_callback, set_approval_callback
target = tmp_path / "AGENTS.md"
target.write_text("rules\n", encoding="utf-8")
stop_event = threading.Event()
callback = _get_approval_callback()
def answer_after_script_died():
stop_event.set()
return "once"
approvals["answer"] = answer_after_script_died
def dispatch(tool_name, tool_args):
set_approval_callback(callback) # the RPC thread carries the cell's callback
try:
return write_file_tool(str(target), "injected")
finally:
set_approval_callback(None)
reply = json.loads(_drive_rpc_once(dispatch, stop_event))
assert "NOT applied" in reply.get("error", "")
assert target.read_text(encoding="utf-8") == "rules\n"
assert len(approvals["calls"]) == 1
def test_retired_kernel_cell_counts_as_gone():
import tools.approval as A
cell = {"active": True}
previous = A.also_gone_when(lambda: not cell["active"])
try:
assert A.approval_arrived_too_late() is False
cell["active"] = False
assert A.approval_arrived_too_late() is True
finally:
A.restore_approval_reply_deadline(previous)
assert A.approval_arrived_too_late() is False
# ---- an automatic approver is not a person ----------------------------------
def test_subagent_auto_approve_cannot_open_the_gate(tmp_path):
from tools.delegate_tool import _subagent_auto_approve
from tools.terminal_tool import set_approval_callback
target = tmp_path / "AGENTS.md"
set_approval_callback(_subagent_auto_approve)
try:
res = _write(target)
finally:
set_approval_callback(None)
assert res.get("error") and "BLOCKED" in res["error"]
assert not target.exists()
assert [r["reason"] for r in _records()] == ["no_interactive_user"]
def test_blocked_message_survives_braces_in_target(tmp_path):
import tools.file_tools as ft
res = ft._request_protected_instruction_approval(["{be}/AGENTS.md"])
assert res.startswith("BLOCKED") and "{be}/AGENTS.md" in res
# ---- sibling gates in a one-shot run ----------------------------------------
def test_computer_use_does_not_wait_in_one_shot_run(one_shot, monkeypatch):
import tools.computer_use.tool as cu
calls = []
monkeypatch.setattr(cu, "_approval_callback", lambda *a, **k: calls.append(a) or "approve_once")
res = cu._request_approval("click", {}, session_id="unattended-test")
assert res is not None and "one-shot run" in json.loads(res)["error"]
assert calls == []
def test_computer_use_still_asks_when_interactive(monkeypatch):
import tools.computer_use.tool as cu
calls = []
monkeypatch.setattr(cu, "_approval_callback", lambda *a, **k: calls.append(a) or "approve_once")
assert cu._request_approval("click", {}, session_id="attended-test") is None
assert len(calls) == 1
def test_memory_write_prompt_unavailable_in_one_shot_run(approvals, monkeypatch):
import tools.write_approval as wa
assert wa._interactive_approval_available() is True
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
assert wa._interactive_approval_available() is False
def test_one_shot_marker_is_not_inherited_by_child_processes(one_shot):
from tools.environments.local import _make_run_env
assert "HERMES_SINGLE_QUERY_SESSION" not in _make_run_env({})
+109
View File
@@ -78,6 +78,115 @@ _hermes_interactive_ctx: contextvars.ContextVar[Optional[str]] = contextvars.Con
)
# Absolute ``time.monotonic()`` instant after which whoever issued the
# current tool call has stopped waiting for its result. Set by the
# execute_code RPC dispatchers, whose sandbox client gives up after a fixed
# reply timeout. An approval that resolves after this instant must NOT be
# acted on: the caller already reported the call as failed, so applying it
# would be a write the agent believes never happened (and may redo).
# The reply timeout is not the only way a caller goes away: the script can
# hit its own run timeout or be interrupted first. ``gone`` is a probe the
# dispatcher supplies for that ("has my script/cell already ended?").
# Thread-local on purpose: the dispatcher and the tool handler it calls run
# on the same thread, whatever context the handler is re-bound to.
_approval_reply_deadline = threading.local()
def set_approval_reply_deadline(deadline: Optional[float], gone=None):
"""Bind the caller's reply window for this thread; return the prior binding.
``deadline`` is monotonic seconds (None = unbounded); ``gone`` is an
optional zero-argument callable that turns true once the caller has
ended. Pass the returned value to :func:`restore_approval_reply_deadline`.
"""
previous = (
getattr(_approval_reply_deadline, "value", None),
getattr(_approval_reply_deadline, "gone", None),
)
_approval_reply_deadline.value = deadline
_approval_reply_deadline.gone = gone
return previous
def restore_approval_reply_deadline(previous) -> None:
"""Undo :func:`set_approval_reply_deadline` / :func:`also_gone_when`."""
deadline, gone = previous if previous else (None, None)
_approval_reply_deadline.value = deadline
_approval_reply_deadline.gone = gone
def also_gone_when(probe):
"""Add another "caller has ended" probe, keeping the current window."""
deadline = getattr(_approval_reply_deadline, "value", None)
current = getattr(_approval_reply_deadline, "gone", None)
def gone() -> bool:
return bool(probe()) or bool(current and current())
return set_approval_reply_deadline(deadline, gone)
def approval_reply_window_remaining() -> Optional[float]:
"""Seconds left before the caller stops waiting, or None when unbounded."""
deadline = getattr(_approval_reply_deadline, "value", None)
if deadline is None:
return None
return deadline - time.monotonic()
def approval_arrived_too_late() -> bool:
"""True when the caller of the current tool call has stopped waiting."""
gone = getattr(_approval_reply_deadline, "gone", None)
if gone is not None:
try:
if gone():
return True
except Exception:
pass
remaining = approval_reply_window_remaining()
return remaining is not None and remaining <= 0
# Approvals that could not be put in front of a human because the run has
# no interactive channel (single-query ``chat -Q`` stage runs, scripts).
# The single-query CLI reports them on stderr as ``approval_required:``
# lines so the coordinating process gets a machine-readable outcome
# instead of inferring one from prose. Bounded: long-lived processes that
# never drain it must not grow without limit.
_human_approval_required: list[dict] = []
_HUMAN_APPROVAL_REQUIRED_MAX = 50
_human_approval_required_lock = threading.Lock()
def note_human_approval_required(kind: str, targets: list[str], reason: str) -> dict:
"""Record an approval the run could not present to a human.
Captures the active session/tool-call identity so the coordinator can
tie the outcome to the exact job and resume the same session.
"""
record = {
"kind": kind,
"targets": list(dict.fromkeys(str(t) for t in targets)),
"reason": reason,
"request_id": uuid.uuid4().hex,
"tool_call_id": _approval_tool_call_id.get() or "",
"session_id": _approval_session_id.get() or os.getenv("HERMES_SESSION_ID", ""),
"applied": False,
}
with _human_approval_required_lock:
_human_approval_required.append(record)
del _human_approval_required[:-_HUMAN_APPROVAL_REQUIRED_MAX]
return dict(record)
def drain_human_approval_required() -> list[dict]:
"""Return and clear the recorded approval-required outcomes."""
with _human_approval_required_lock:
records = list(_human_approval_required)
_human_approval_required.clear()
return records
def set_hermes_interactive_context(interactive: bool) -> contextvars.Token:
"""Bind interactive mode for the current context (thread or asyncio task).
+39
View File
@@ -716,6 +716,36 @@ def _call(tool_name, args):
# RPC server (runs in a thread inside the parent process)
# ---------------------------------------------------------------------------
# How long the generated sandbox client waits for one tool-call reply
# (``_sock.settimeout(300)`` / the file transport's 300s poll deadline).
_RPC_REPLY_TIMEOUT_SECONDS = 300
def _bind_rpc_reply_deadline(call_start: float, stop_event=None):
"""Tell the approval gates when this call's requester stops waiting.
The requester is gone at the reply timeout, or earlier when the script
itself ended (run timeout, interrupt): ``stop_event`` is set then.
"""
try:
from tools.approval import set_approval_reply_deadline
gone = stop_event.is_set if stop_event is not None else None
return (True, set_approval_reply_deadline(
call_start + _RPC_REPLY_TIMEOUT_SECONDS, gone))
except Exception:
return (False, None)
def _restore_rpc_reply_deadline(previous) -> None:
if not previous or not previous[0]:
return
try:
from tools.approval import restore_approval_reply_deadline
restore_approval_reply_deadline(previous[1])
except Exception:
pass
# Terminal parameters that must not be used from ephemeral sandbox scripts
_TERMINAL_BLOCKED_PARAMS = {"background", "pty", "notify", "notify_on_complete", "watch_patterns"}
@@ -826,12 +856,18 @@ def _rpc_server_loop(
# Dispatch through the standard tool handler.
# Suppress stdout/stderr from internal tool handlers so
# their status prints don't leak into the CLI spinner.
# The sandbox client stops waiting for this reply after
# _RPC_REPLY_TIMEOUT_SECONDS; an approval granted later
# must not apply a write the script already saw fail.
_prev_deadline = _bind_rpc_reply_deadline(call_start, stop_event)
try:
with thread_scoped_silence():
result = dispatch(tool_name, tool_args)
except Exception as exc:
logger.error("Tool call failed in sandbox: %s", exc, exc_info=True)
result = tool_error(str(exc))
finally:
_restore_rpc_reply_deadline(_prev_deadline)
tool_call_counter[0] += 1
call_duration = time.monotonic() - call_start
@@ -1101,6 +1137,7 @@ def _rpc_poll_loop(
tool_args.pop(param, None)
# Dispatch through the standard tool handler
_prev_deadline = _bind_rpc_reply_deadline(call_start, stop_event)
try:
with thread_scoped_silence():
tool_result = handle_function_call(
@@ -1110,6 +1147,8 @@ def _rpc_poll_loop(
logger.error("Tool call failed in remote sandbox: %s",
exc, exc_info=True)
tool_result = tool_error(str(exc))
finally:
_restore_rpc_reply_deadline(_prev_deadline)
tool_call_counter[0] += 1
call_duration = time.monotonic() - call_start
+16
View File
@@ -322,9 +322,25 @@ class CellAuthority:
set_sudo(self._sudo_cb)
except Exception:
previous = None
# A cell that settled (timeout, interrupt, error) no longer waits for
# this call: an approval granted afterwards must not be applied.
reply_window = None
try:
from tools.approval import also_gone_when
reply_window = (also_gone_when(lambda: not self.active),)
except Exception:
reply_window = None
try:
return handle_function_call(tool_name, tool_args, task_id=self.task_id)
finally:
if reply_window is not None:
try:
from tools.approval import restore_approval_reply_deadline
restore_approval_reply_deadline(reply_window[0])
except Exception:
pass
if previous is not None and self._callback_setters is not None:
set_approval, set_sudo = self._callback_setters
try:
+16
View File
@@ -613,6 +613,22 @@ def _request_approval(action: str, args: Dict[str, Any],
# No CLI approval wired — default allow. Gateway approval is handled
# one layer out via the normal tool-approval infra.
return None
try:
from tools.approval import _is_single_query_approval_context
unattended = _is_single_query_approval_context()
except Exception:
unattended = False
if unattended:
# One-shot run: the prompt callback is installed but its panel is
# never shown, so asking would only wait out approvals.timeout.
return json.dumps({
"error": (
"approval required but this is a one-shot run with no "
"interactive user to approve it. Silence is not consent; "
"do not retry without the user."
),
"action": action,
})
summary = _summarize_action(action, args)
try:
verdict = cb(action, args, summary)
+5
View File
@@ -102,6 +102,11 @@ def _subagent_auto_approve(command: str, description: str, **kwargs) -> str:
return "once"
# Not a person: gates that require real human consent every time (protected
# instruction files) must not accept this callback's answer.
_subagent_auto_approve.unattended = True
def _get_subagent_approval_callback():
"""Return the callback to install into subagent worker threads.
+4
View File
@@ -1546,6 +1546,10 @@ def _make_run_env(env: dict) -> dict:
run_env[real_key] = v
elif _is_hermes_internal_secret(k):
continue
elif k == "HERMES_SINGLE_QUERY_SESSION":
# Describes this process only. A child (an interactive hermes
# started from a one-shot run's terminal) sets its own.
continue
else:
first_party = _is_terminal_first_party_env(k)
passthrough = _is_passthrough(k)
+79 -14
View File
@@ -861,12 +861,18 @@ def _request_protected_instruction_approval(
"required (not bypassed by auto-approve)."
)
display = f"<write to {targets}>"
blocked = (
f"BLOCKED: write to protected agent-instruction file(s) ({targets}) "
"{why} The user has NOT consented to this write. Do NOT retry it or "
"attempt the same edit via another path (terminal, execute_code, "
"etc.)."
)
class _Blocked:
# Not str.format: a target path may itself contain braces.
@staticmethod
def format(why: str) -> str:
return (
"BLOCKED: write to protected agent-instruction file(s) "
f"({targets}) {why} The user has NOT consented to this "
"write. Do NOT retry it or attempt the same edit via "
"another path (terminal, execute_code, etc.)."
)
blocked = _Blocked
try:
import tools.approval as _approval
@@ -874,6 +880,41 @@ def _request_protected_instruction_approval(
return blocked.format(why="requires approval but the approval "
"subsystem is unavailable.")
def _needs_human(reason: str, why: str) -> str:
# No human could be shown this request. Record it so a one-shot
# run can hand its coordinator a machine-readable outcome, and tell
# the agent where a human CAN approve it instead of waiting on a
# prompt nobody sees.
try:
_approval.note_human_approval_required(
"protected_instruction_file", list(reasons), reason)
except Exception:
pass
return blocked.format(
why=why + " APPROVAL REQUIRED: a human must approve this from "
"an interactive session (resume this same session in "
"the desktop app or an interactive terminal and "
"request the write again there).")
def _too_late() -> str | None:
# The approval came back after whoever issued this call stopped
# waiting (execute_code RPC reply timeout). The caller already saw
# a failure, so applying the write now would be an edit the agent
# believes never happened.
try:
late = _approval.approval_arrived_too_late()
except Exception:
late = False
if not late:
return None
logger.warning(
"Approval for protected instruction file(s) %s arrived after "
"the caller stopped waiting; write not applied.", targets)
return blocked.format(
why="was approved only after the calling script had stopped "
"waiting for the result, so the write was NOT applied. "
"The approval is spent; a new request needs a new approval.")
# Gateway surface: block on the button round-trip when a notify callback
# is registered for this session (Telegram/Discord/Slack). One-operation
# only — no session/permanent buttons are offered.
@@ -898,14 +939,15 @@ def _request_protected_instruction_approval(
session_key, notify_cb, approval_data, surface="gateway",
)
if decision.get("notify_failed"):
return blocked.format(
why="requires approval but the approval request could not "
"be delivered.")
return _needs_human(
"approval_not_delivered",
"requires approval but the approval request could not "
"be delivered.")
choice = decision.get("choice")
if decision.get("resolved") and choice in {"once", "session", "always"}:
# One-operation grant regardless of the tapped scope — nothing
# is persisted for this gate.
return None
return _too_late()
if not decision.get("resolved"):
return blocked.format(
why="approval prompt timed out without a user response. "
@@ -921,6 +963,28 @@ def _request_protected_instruction_approval(
callback = None
if callback is not None:
# A one-shot run (``chat -q`` / ``--query-file``) installs the
# prompt callback but never starts the prompt UI: the panel would
# be painted nowhere and the wait would last the whole
# approvals.timeout with nobody able to answer. A registered
# callback is not a reachable human. Fail closed NOW;
# approvals.single_query_mode does not apply to this gate.
try:
unattended = _approval._is_single_query_approval_context()
except Exception:
unattended = False
if unattended:
return _needs_human(
"no_interactive_user",
"requires approval but this is a one-shot run with no "
"interactive user to approve it.")
if getattr(callback, "unattended", False):
# An auto-answering callback (subagent auto-approve) is not a
# person; this gate takes consent from a human only.
return _needs_human(
"no_interactive_user",
"requires approval but only an automatic approver is "
"attached to this run, not a person.")
choice = _approval.prompt_dangerous_approval(
display, description,
allow_permanent=False,
@@ -929,7 +993,7 @@ def _request_protected_instruction_approval(
)
if choice in {"once", "session", "always"}:
# One-operation grant; never persisted (see docstring).
return None
return _too_late()
if choice == "timeout":
return blocked.format(
why="approval prompt timed out without a user response. "
@@ -938,9 +1002,10 @@ def _request_protected_instruction_approval(
# No human channel at all (script, cron, background thread): fail
# closed. Auto-approving here would recreate the persistence vector.
return blocked.format(
why="requires approval but no interactive user or gateway is "
"present to approve it.")
return _needs_human(
"no_approval_channel",
"requires approval but no interactive user or gateway is "
"present to approve it.")
def _check_protected_instruction_write(paths: list[str],
+8
View File
@@ -327,6 +327,14 @@ def _interactive_approval_available() -> bool:
review affordance (``/memory pending``) instead.
* Scripts, cron, and background threads — no user present.
"""
try:
from tools.approval import _is_single_query_approval_context
if _is_single_query_approval_context():
# One-shot run: the callback exists but its panel is never
# shown, so an inline prompt would wait with nobody to answer.
return False
except Exception:
pass
try:
from tools.terminal_tool import _get_approval_callback
return _get_approval_callback() is not None