Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -787,8 +787,12 @@ jobs:
run: >-
npm run test:e2e --
assistant-activity.spec.ts
auth-connection-recovery.spec.ts
chat-send-lifecycle.spec.ts
chat-send-lifecycle.real.spec.ts
composer-paste.spec.ts
history-hydration.spec.ts
idle-chat-recovery.spec.ts
provider-error-experience.spec.ts
user-message-newlines.spec.ts
session-created-card.spec.ts
Expand Down Expand Up @@ -1731,10 +1735,14 @@ jobs:
else
unset OPENSQUILLA_REQUIRE_ELECTRON_FOREGROUND
fi
set --
if [[ "${name}" == "window-background-flow" && "${RUNNER_OS}" == "macOS" ]]; then
set -- --connection-faults --flow-control --idle-send --background-ms=65000
fi
if [[ "${RUNNER_OS}" == "Linux" ]]; then
"${telemetry[@]}" xvfb-run -a node "${script}" 2>&1 | tee "${log_path}"
"${telemetry[@]}" xvfb-run -a node "${script}" "$@" 2>&1 | tee "${log_path}"
else
"${telemetry[@]}" node "${script}" 2>&1 | tee "${log_path}"
"${telemetry[@]}" node "${script}" "$@" 2>&1 | tee "${log_path}"
fi
}

Expand Down
132 changes: 128 additions & 4 deletions desktop/electron/scripts/test-desktop-window-background-flow.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { strict as assert } from 'node:assert'
import { mkdir, mkdtemp, open, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
Expand All @@ -8,7 +9,9 @@ import { _electron as electron } from 'playwright'
import {
canAcceptWindowsElectronShutdownFallback,
closeElectronWithDeadline,
closeHttpServerWithDeadline,
desktopShutdownEvidenceSince,
trackHttpServerConnections,
} from './e2e-shutdown-helpers.mjs'

const scriptDir = dirname(fileURLToPath(import.meta.url))
Expand All @@ -17,6 +20,12 @@ const repoRoot = resolve(packageRoot, '../..')
const ELECTRON_SHUTDOWN_TIMEOUT_MS = 15_000
const flowControl = process.argv.includes('--flow-control')
const connectionFaults = process.argv.includes('--connection-faults')
const idleSend = process.argv.includes('--idle-send')
const backgroundOption = process.argv.find(value => value.startsWith('--background-ms='))
const backgroundMs = backgroundOption ? Number(backgroundOption.split('=')[1]) : 65_000
if (!Number.isInteger(backgroundMs) || backgroundMs < 5_000 || backgroundMs > 600_000) {
throw new Error('background-ms must be an integer between 5000 and 600000')
}
const outageOption = process.argv.find(value => value.startsWith('--outage-ms='))
const outageMs = outageOption ? Number(outageOption.split('=')[1]) : 5_000
if (!Number.isInteger(outageMs) || outageMs < 5_000 || outageMs > 600_000) {
Expand Down Expand Up @@ -71,9 +80,55 @@ let negotiatedFlow = false
let warmRecoveryMs = null
let continuityPage
let continuityDiagnostics = null
let syntheticProvider
let acceptedIdleSends = 0
let sentIdleMessages = 0
let idleSendReceipt = null
let hiddenIdleMs = null
let idleRecoveryMs = null
const idleSendIds = new Set()
const routedClients = new Set()
const routedServers = new WeakMap()

async function startSyntheticProvider() {
let chatRequests = 0
const server = createServer((request, response) => {
request.resume()
request.once('end', () => {
if (request.method !== 'POST' || request.url !== '/api/chat') {
response.writeHead(404)
response.end()
return
}
chatRequests++
response.writeHead(200, { 'content-type': 'application/x-ndjson' })
response.end(JSON.stringify({
model: 'opensquilla-window-close-test-model',
created_at: '2026-01-01T00:00:00Z',
message: { role: 'assistant', content: 'Synthetic background recovery complete.' },
done: true,
done_reason: 'stop',
prompt_eval_count: 8,
eval_count: 3,
}) + '\n')
})
})
const sockets = trackHttpServerConnections(server)
await new Promise((resolveListen, rejectListen) => {
server.once('error', rejectListen)
server.listen(0, '127.0.0.1', resolveListen)
})
const address = server.address()
assert.ok(address && typeof address === 'object')
return {
baseUrl: `http://127.0.0.1:${address.port}`,
chatRequests: () => chatRequests,
close: () => closeHttpServerWithDeadline(server, sockets, {
label: 'background-flow synthetic provider shutdown',
}),
}
}

// Observe only lifecycle/element categories, never text, values, URLs or keys.
// The ring is bounded and records transitions, not every DOM mutation/poll.
async function installContinuityObservation(page, app) {
Expand Down Expand Up @@ -170,14 +225,15 @@ try {
await mkdir(isolatedHome, { recursive: true })
await mkdir(isolatedRoaming, { recursive: true })
await mkdir(isolatedLocal, { recursive: true })
if (idleSend) syntheticProvider = await startSyntheticProvider()

// Use a synthetic keyless profile so the lifecycle test reaches the Control
// UI without reading developer credentials or requiring an external model.
const now = new Date().toISOString()
await writeFile(join(userDataDir, 'desktop-credential.json'), JSON.stringify({
provider: 'ollama',
model: 'opensquilla-window-close-test-model',
baseUrl: 'http://127.0.0.1:11434',
baseUrl: syntheticProvider?.baseUrl || 'http://127.0.0.1:11434',
apiKeyEnv: '',
encryptedApiKey: '',
modelRoutingMode: 'direct',
Expand All @@ -188,7 +244,7 @@ try {
searchApiKeyEnv: '',
encryptedSearchApiKey: '',
encryption: 'plain',
disableNetworkObservability: false,
disableNetworkObservability: idleSend,
createdAt: now,
updatedAt: now,
}, null, 2), { mode: 0o600 })
Expand All @@ -213,6 +269,13 @@ try {
OPENSQUILLA_AUTH_MODE: 'token',
OPENSQUILLA_AUTH_TOKEN: 'synthetic-window-flow-operator-token',
OPENSQUILLA_GATEWAY_WS_TRANSPORT_FLOW_ENABLED: flowControl ? 'true' : 'false',
...(idleSend ? {
// Match the synthetic first-send gate's model budget so bundled tool
// schemas do not exhaust Ollama's small fallback context before HTTP.
OPENSQUILLA_LLM_CONTEXT_WINDOW_TOKENS: '131072',
NO_PROXY: '127.0.0.1,localhost,::1',
no_proxy: '127.0.0.1,localhost,::1',
} : {}),
},
})

Expand Down Expand Up @@ -241,10 +304,25 @@ try {
routedClients.delete(client)
void client.close({ code, reason })
})
client.onMessage(message => {
try {
const frame = JSON.parse(String(message))
if (frame.type === 'req' && frame.method === 'chat.send') {
sentIdleMessages++
idleSendIds.add(frame.id)
}
} catch { /* Non-JSON frames remain transparent. */ }
server.send(message)
})
server.onMessage(message => {
try {
const frame = JSON.parse(String(message))
if (frame?.policy?.transport_flow?.delivery_epoch) negotiatedFlow = true
if (frame?.type === 'res' && idleSendIds.has(frame.id)) {
idleSendIds.delete(frame.id)
idleSendReceipt = { ok: frame.ok, accepted: frame.payload?.accepted }
if (frame.ok === true && frame.payload?.accepted === true) acceptedIdleSends++
}
} catch { /* Non-JSON frames remain transparent. */ }
client.send(message)
})
Expand All @@ -269,7 +347,7 @@ try {
)
// Fixture setup only: install interception before the measured connection.
// No reload, refresh or navigation is permitted during fault recovery.
if (connectionFaults) {
if (connectionFaults || idleSend) {
await installFaultRoute()
await page.reload({ waitUntil: 'domcontentloaded' })
}
Expand All @@ -290,7 +368,7 @@ try {
// the developer's computer. The signal must preserve the live renderer.
const composer = page.locator('.chat-textarea').first()
await composer.waitFor({ state: 'visible', timeout: 60_000 })
const draft = 'isolated stability draft - never send'
const draft = idleSend ? 'Synthetic background recovery request.' : 'isolated stability draft - never send'
await composer.fill(draft)
await composer.focus()
const resumeUrl = page.url()
Expand Down Expand Up @@ -358,6 +436,7 @@ try {

const backgroundSupported = runtimeIsolation.platform === 'darwin'
|| runtimeIsolation.platform === 'win32'
assert.ok(!idleSend || backgroundSupported, 'idle-send requires native background-window support')
assert.equal(preferences.canRunInBackground, backgroundSupported)
assert.equal(
preferences.mainWindowCloseBehavior,
Expand Down Expand Up @@ -413,6 +492,24 @@ try {
)
assert.equal(page.isClosed(), false)

if (idleSend) {
// Exercise real native hide/activate and an interrupted transport. This
// deliberately does not claim to suspend the physical host computer.
outage = true
for (const client of [...routedClients]) {
routedClients.delete(client)
await client.close({ code: 1013, reason: 'Synthetic hidden-window interruption' })
await routedServers.get(client)?.close({ code: 1013, reason: 'Synthetic hidden-window interruption' })
}
const hiddenStarted = Date.now()
console.log(JSON.stringify({ event: 'desktop_idle_send_phase', phase: 'hidden', backgroundMs }))
await delay(backgroundMs)
hiddenIdleMs = Date.now() - hiddenStarted
assert.equal(await composer.inputValue(), draft, 'hidden idle must retain the draft')
outage = false
}

const revealStarted = Date.now()
await desktopApp.evaluate(({ app }) => {
app.emit('activate')
})
Expand All @@ -436,6 +533,23 @@ try {
'revealing a hidden desktop window must preserve renderer state',
)

if (idleSend) {
const sendButton = page.locator('.chat-send-btn.btn--primary')
await waitFor(async () => !await sendButton.isDisabled(), 'send after native background return', 30_000)
idleRecoveryMs = Date.now() - revealStarted
assert.equal(await composer.inputValue(), draft, 'native return must retain the unsent draft')
assert.equal(await page.evaluate(() => window.__stabilityComposer === document.querySelector('.chat-textarea')), true)
await sendButton.click()
await waitFor(() => idleSendReceipt, 'real Gateway background-return admission')
assert.deepEqual(idleSendReceipt, { ok: true, accepted: true }, 'real Gateway must admit the send')
await waitFor(async () => (
await page.locator('.msg-ai-text').last().textContent({ timeout: 1_000 })
)?.includes('Synthetic background recovery complete.'), 'synthetic provider response')
assert.equal(sentIdleMessages, 1, 'background return must submit the draft exactly once')
assert.equal(syntheticProvider.chatRequests(), 1, 'accepted send must reach the local provider once')
assert.equal(await composer.inputValue(), '', 'accepted send must clear the draft')
}

await desktopApp.evaluate(({ app, BrowserWindow }) => {
const window = BrowserWindow.getAllWindows().find((candidate) => (
candidate.webContents.getURL().startsWith('opensquilla-app://desktop/')
Expand Down Expand Up @@ -586,6 +700,12 @@ try {
reconnectAttempts,
warmRecoveryMs,
continuityDiagnostics,
idleSend,
backgroundMs: idleSend ? backgroundMs : null,
hiddenIdleMs,
idleRecoveryMs,
acceptedIdleSends,
syntheticProviderRequests: syntheticProvider?.chatRequests() ?? null,
}, null, 2))
}
flowSucceeded = true
Expand All @@ -610,6 +730,9 @@ try {
routedConnectionCount: routedClients.size,
acceptedSockets,
negotiatedFlow,
sentIdleMessages,
idleSendReceipt,
syntheticProviderRequests: syntheticProvider?.chatRequests() ?? null,
continuityDiagnostics,
windows,
desktopLog,
Expand Down Expand Up @@ -755,5 +878,6 @@ try {
} else {
await rm(isolationRoot, { recursive: true, force: true }).catch(() => {})
}
await syntheticProvider?.close()
if (flowSucceeded && shutdownError) throw shutdownError
}
79 changes: 79 additions & 0 deletions opensquilla-webui/e2e/auth-connection-gateway.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Isolated real WebSocket auth boundary for browser credential recovery tests."""

from __future__ import annotations

import asyncio
import json
import sys
from pathlib import Path

import uvicorn
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route, WebSocketRoute

from opensquilla.gateway.config import AuthConfig, GatewayConfig
from opensquilla.gateway.rpc import get_dispatcher
from opensquilla.gateway.websocket import (
SubscriptionManager,
get_registry,
handle_ws_connection,
)
from opensquilla.session.manager import SessionManager
from opensquilla.session.storage import SessionStorage

TOKEN = "synthetic-browser-gateway-token"


async def main() -> None:
origin, state_path, mode = sys.argv[1:]
state = Path(state_path)
state.mkdir(parents=True, exist_ok=True)
config = GatewayConfig(
host="127.0.0.1",
state_dir=str(state),
auth=AuthConfig(mode=mode, token=TOKEN),
)
config.cors.allowed_origins = [origin]
storage = await SessionStorage.open(str(state / "sessions.db"))
manager = SessionManager(storage)
subscriptions = SubscriptionManager()

async def websocket(ws) -> None:
await handle_ws_connection(
ws,
config,
dispatcher=get_dispatcher(),
session_manager=manager,
subscription_manager=subscriptions,
)

async def enable_token(_request):
config.auth = AuthConfig(mode="token", token=TOKEN)
# A configuration restart applies the new auth policy on fresh handshakes.
for connection in get_registry().all():
await connection.close(code=1012, reason="synthetic_auth_configuration_restart")
return JSONResponse({"mode": "token"})

app = Starlette(routes=[
WebSocketRoute("/ws", websocket),
Route("/enable-token", enable_token, methods=["POST"]),
])
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=0, log_level="warning"))
task = asyncio.create_task(server.serve())
try:
while not server.started:
if task.done():
await task
raise RuntimeError("Auth fixture stopped before becoming ready")
await asyncio.sleep(0.01)
port = server.servers[0].sockets[0].getsockname()[1]
print(json.dumps({"authFixturePort": port}), flush=True)
await task
finally:
server.should_exit = True
await storage.close()


if __name__ == "__main__":
asyncio.run(main())
Loading
Loading