android-midscene-automation 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/README.md +15 -1
  3. package/package.json +1 -1
  4. package/remote-agent/index.ts +50 -22
  5. package/server/analytics.test.ts +46 -0
  6. package/server/analytics.ts +24 -0
  7. package/server/api-safety-analytics.test.ts +57 -0
  8. package/server/appium-auto-save.test.ts +6 -0
  9. package/server/appium-recorder/appium-runner.ts +30 -9
  10. package/server/appium-recorder/appium-version.test.ts +55 -0
  11. package/server/appium-recorder/managed-appium.ts +37 -1
  12. package/server/appium-recorder/managed-cleanup.test.ts +38 -8
  13. package/server/appium-recorder/replay-lifecycle.test.ts +123 -0
  14. package/server/appium-recorder/routes.ts +8 -18
  15. package/server/appium-recorder/run-history.ts +11 -0
  16. package/server/config-access.test.ts +27 -0
  17. package/server/config-access.ts +27 -0
  18. package/server/http-api.ts +63 -54
  19. package/server/operations/repository.ts +8 -0
  20. package/server/remote-agents/protocol.ts +1 -0
  21. package/server/remote-agents/registry.test.ts +65 -0
  22. package/server/remote-agents/registry.ts +60 -5
  23. package/server/remote-agents/replay-integration.test.ts +79 -0
  24. package/server/remote-agents/routes.ts +12 -15
  25. package/server/request-body.test.ts +40 -0
  26. package/server/request-body.ts +48 -0
  27. package/src/App.vue +96 -758
  28. package/src/analytics/summary.ts +75 -0
  29. package/src/api.ts +11 -0
  30. package/src/appium-recorder/AppiumPage.vue +4 -1
  31. package/src/components/AppSidebar.vue +31 -0
  32. package/src/components/analytics/AnalysisCharts.vue +189 -0
  33. package/src/components/analytics/ReportAnalysis.vue +78 -0
  34. package/src/components/config/AppiumConfigPanel.vue +4 -1
  35. package/src/components/config/AppiumModelSettings.vue +1 -1
  36. package/src/components/config/BaseConfigPanel.vue +6 -2
  37. package/src/components/config/MidsceneConfigPanel.vue +4 -5
  38. package/src/components/help/HelpCenter.vue +185 -0
  39. package/src/composables/use-device-preview.ts +279 -0
  40. package/src/composables/use-initial-resource.ts +17 -0
  41. package/src/composables/use-script-execution.ts +277 -0
  42. package/src/config/credentials.ts +2 -0
  43. package/src/config/use-appium-auto-save.ts +22 -11
  44. package/src/config/use-model-config.ts +157 -0
  45. package/src/main.ts +2 -2
  46. package/src/pages/AnalysisPage.vue +60 -0
  47. package/src/pages/ConfigPage.vue +36 -5
  48. package/src/style.css +711 -123
  49. package/src/types.ts +7 -1
  50. package/src/components/help/HelpCenterDialog.vue +0 -160
  51. package/src/ui-refresh.css +0 -677
@@ -8,12 +8,14 @@ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
 
11
- test('owned device instrumentation is cleaned after success, abort and failed session creation; external servers are untouched', async () => {
11
+ test('replay survives command idle periods and cleans sessions after success, abort and creation failure', async () => {
12
12
  const root = await mkdtemp(join(tmpdir(), 'managed-cleanup-'));
13
13
  const env = { ...process.env };
14
14
  const original = { execFile: childProcess.execFile, spawn: childProcess.spawn, fetch: globalThis.fetch };
15
15
  const calls: string[][] = [];
16
16
  let mode = 'success', stopped = false;
17
+ let elapsedMs = 0, lastCommandMs = 0, commandTimeoutMs = 60_000;
18
+ let keyCommands = 0, deletedSessions = 0;
17
19
  let controller = new AbortController();
18
20
  try {
19
21
  const sdk = join(root, 'sdk');
@@ -44,8 +46,20 @@ test('owned device instrumentation is cleaned after success, abort and failed se
44
46
  const path = new URL(String(input)).pathname;
45
47
  if (path === '/session' && init?.method === 'POST') {
46
48
  if (mode === 'creation-failure') return Response.json({ value: { error: 'session not created', message: 'creation failed' } }, { status: 500 });
49
+ const capabilities = JSON.parse(String(init.body)).capabilities.alwaysMatch;
50
+ commandTimeoutMs = (capabilities['appium:newCommandTimeout'] ?? 60) * 1000;
51
+ lastCommandMs = elapsedMs;
47
52
  return Response.json({ value: { sessionId: 'test-session' } });
48
53
  }
54
+ if (path.endsWith('/press_keycode')) {
55
+ keyCommands++;
56
+ // Model Appium's idle expiry without waiting a real minute or touching a device.
57
+ if (commandTimeoutMs && elapsedMs - lastCommandMs > commandTimeoutMs) {
58
+ return Response.json({ value: { error: 'invalid session id', message: 'New Command Timeout expired' } }, { status: 404 });
59
+ }
60
+ lastCommandMs = elapsedMs;
61
+ }
62
+ if (path === '/session/test-session' && init?.method === 'DELETE') deletedSessions++;
49
63
  return Response.json({ value: {} });
50
64
  };
51
65
  const { replayAppiumScript } = await import('./appium-runner');
@@ -53,19 +67,35 @@ test('owned device instrumentation is cleaned after success, abort and failed se
53
67
  id: 'cleanup', name: 'cleanup', deviceId: 'test-device', appPackage: '', appActivity: '', createdAt: '', updatedAt: '',
54
68
  steps: [{ id: 'log', type: 'log' as const, label: 'log', value: 'ok' }],
55
69
  };
56
- for (mode of ['success', 'abort', 'creation-failure', 'cleanup-failure', 'external']) {
70
+ for (mode of ['success', 'long-idle', 'abort', 'creation-failure', 'cleanup-failure', 'external', 'external-abort']) {
57
71
  calls.length = 0;
72
+ elapsedMs = lastCommandMs = keyCommands = deletedSessions = 0;
58
73
  controller = new AbortController();
59
- if (mode === 'external') process.env.APPIUM_SERVER_URL = 'http://external.invalid';
60
- const result = await replayAppiumScript(script, 'test-device', line => {
61
- if (mode === 'abort' && line.includes('Appium session 已创建')) controller.abort();
74
+ const external = mode.startsWith('external');
75
+ const aborted = mode.endsWith('abort');
76
+ const longIdle = mode.endsWith('long-idle');
77
+ if (external) process.env.APPIUM_SERVER_URL = 'http://external.invalid';
78
+ else delete process.env.APPIUM_SERVER_URL;
79
+ const replayScript = longIdle ? { ...script, steps: [
80
+ ...script.steps,
81
+ { id: 'home', type: 'key' as const, label: 'Home', keyCode: 3 },
82
+ ] } : script;
83
+ const result = await replayAppiumScript(replayScript, 'test-device', line => {
84
+ // ADB operations and model inference do not reset Appium's command timer.
85
+ if (longIdle && line.includes('[节点 1] 完成')) elapsedMs += 120_000;
86
+ if (aborted && line.includes('Appium session 已创建')) controller.abort();
62
87
  }, controller.signal);
63
- assert.equal(calls.length, mode === 'external' ? 0 : 2);
64
- if (mode !== 'external') {
88
+ assert.equal(calls.length, external ? 0 : 2);
89
+ assert.equal(deletedSessions, mode === 'creation-failure' || (aborted && !external) ? 0 : 1, 'release the replay session even when idle expiry is disabled');
90
+ if (!external) {
65
91
  assert.deepEqual(calls.map(args => args.slice(0, 5)), Array(2).fill(['-s', 'test-device', 'shell', 'am', 'force-stop']));
66
92
  assert.deepEqual(calls.map(args => args.at(-1)).sort(), ['io.appium.uiautomator2.server', 'io.appium.uiautomator2.server.test']);
67
93
  }
68
- if (mode === 'abort') assert.equal(result.stopped, true);
94
+ if (longIdle) {
95
+ assert.equal(keyCommands, 1, 'execute Home after the long interval without Appium commands');
96
+ assert.equal(result.success, true, result.output);
97
+ }
98
+ if (aborted) assert.equal(result.stopped, true);
69
99
  if (mode === 'creation-failure') assert.equal(result.success, false);
70
100
  if (mode === 'cleanup-failure') assert.match(result.output, /UiAutomator2 清理失败/);
71
101
  }
@@ -0,0 +1,123 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import childProcess from 'node:child_process';
4
+ import { syncBuiltinESMExports } from 'node:module';
5
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { createServer } from 'node:http';
9
+ import type { AppiumRecordedScriptRecord, AppiumRecordedStepRecord } from './repository';
10
+
11
+ test('ADB cancellation and deadlines cover actions; external sessions survive idle work and expire after creation cancellation', async () => {
12
+ const root = await mkdtemp(join(tmpdir(), 'replay-lifecycle-'));
13
+ const env = { ...process.env };
14
+ const original = { execFile: childProcess.execFile, fetch: globalThis.fetch, setInterval: globalThis.setInterval };
15
+ let controller = new AbortController();
16
+ let mode = '', deletes = 0, launchedAfterAbort = false;
17
+ let requestCount = 0;
18
+ try {
19
+ const sdk = join(root, 'sdk');
20
+ await mkdir(join(sdk, 'platform-tools'), { recursive: true });
21
+ await writeFile(join(sdk, 'platform-tools', 'adb'), '');
22
+ Object.assign(process.env, { ANDROID_MIDSCENE_DATA_ROOT: root, ANDROID_HOME: sdk, ANDROID_SDK_ROOT: sdk, APPIUM_SERVER_URL: 'http://external.invalid' });
23
+ childProcess.execFile = ((_cmd, args: string[], options, callback) => {
24
+ requestCount++;
25
+ if (mode === 'foreground-abort' && args.includes('activities')) {
26
+ queueMicrotask(() => { controller.abort(); callback(new Error('aborted'), '', ''); });
27
+ } else if (args.includes('monkey') || args.includes('clear') || args.includes('input')) {
28
+ launchedAfterAbort ||= controller.signal.aborted;
29
+ assert.ok(options.timeout > 0, 'every device action has a deadline');
30
+ assert.equal(options.signal, controller.signal);
31
+ if (mode === 'timeout') {
32
+ queueMicrotask(() => callback(new Error('ADB command timed out'), '', ''));
33
+ } else {
34
+ options.signal.addEventListener('abort', () => callback(new Error('aborted'), '', ''), { once: true });
35
+ queueMicrotask(() => controller.abort());
36
+ }
37
+ } else queueMicrotask(() => callback(null, '', ''));
38
+ return {};
39
+ }) as typeof childProcess.execFile;
40
+ syncBuiltinESMExports();
41
+ globalThis.fetch = async (input, init) => {
42
+ if (init?.method === 'DELETE') deletes++;
43
+ return Response.json({ value: new URL(String(input)).pathname === '/session' ? { sessionId: 'test-session' } : {} });
44
+ };
45
+ const { replayAppiumScript, launchAppOnDevice, clearAppDataOnDevice } = await import('./appium-runner');
46
+ const script: AppiumRecordedScriptRecord = { id: 'audit', name: 'audit', deviceId: 'fake-device', appPackage: '', appActivity: '', createdAt: '', updatedAt: '', steps: [] };
47
+ const run = (steps: AppiumRecordedStepRecord[]) => replayAppiumScript({ ...script, steps }, 'fake-device', undefined, controller.signal, { screenshotReport: false, reportSummaryEnabled: false });
48
+ const actions: AppiumRecordedStepRecord[] = [
49
+ { id: 'launch', type: 'launchApp', label: 'launch', value: 'example.test' },
50
+ { id: 'clear', type: 'clearAppData', label: 'clear', value: 'example.test' },
51
+ { id: 'tap', type: 'coordinateTap', label: 'tap', fallback: { strategy: 'bounds', centerX: 10, centerY: 10 } },
52
+ { id: 'swipe', type: 'swipe', label: 'swipe', swipe: { startX: 10, startY: 10, endX: 50, endY: 50, duration: 100 } },
53
+ ];
54
+ for (const action of actions) {
55
+ mode = 'abort'; controller = new AbortController(); deletes = 0;
56
+ assert.equal((await run([action])).stopped, true, action.type);
57
+ assert.equal(deletes, 1, 'abort releases the known external session');
58
+ }
59
+ mode = 'foreground-abort'; controller = new AbortController(); launchedAfterAbort = false;
60
+ assert.equal((await run([actions[0]!])).stopped, true);
61
+ assert.equal(launchedAfterAbort, false, 'foreground cancellation must not launch monkey');
62
+ const count = requestCount;
63
+ assert.throws(() => launchAppOnDevice('fake', 'example.test', controller.signal));
64
+ assert.throws(() => clearAppDataOnDevice('fake', 'example.test', controller.signal));
65
+ assert.equal(requestCount, count, 'already-cancelled actions never spawn');
66
+ mode = 'timeout'; controller = new AbortController(); deletes = 0;
67
+ const failed = await run([actions[0]!]);
68
+ assert.equal(failed.success, false); assert.match(failed.output, /timed out/); assert.equal(deletes, 1);
69
+
70
+ // A local HTTP server models the idle lease at accelerated time, without a real device.
71
+ globalThis.fetch = original.fetch;
72
+ globalThis.setInterval = ((fn, ms, ...args) => original.setInterval(fn, ms === 15000 ? 15 : ms, ...args)) as typeof setInterval;
73
+ let active = false, timeoutSeconds = 0, heartbeats = 0, homeKeys = 0, deleted = 0;
74
+ let expiry: ReturnType<typeof setTimeout> | undefined;
75
+ let createdResolve: () => void = () => {};
76
+ const renew = () => { clearTimeout(expiry); expiry = setTimeout(() => { active = false; }, timeoutSeconds * 3); };
77
+ const server = createServer(async (req, res) => {
78
+ res.setHeader('Content-Type', 'application/json');
79
+ if (req.url === '/session' && req.method === 'POST') {
80
+ let body = ''; for await (const chunk of req) body += chunk;
81
+ timeoutSeconds = JSON.parse(body).capabilities.alwaysMatch['appium:newCommandTimeout'];
82
+ if (mode === 'creation-abort') { controller.abort(); await new Promise(resolve => setTimeout(resolve, 20)); }
83
+ active = true; renew(); createdResolve();
84
+ res.end(JSON.stringify({ value: { sessionId: 'delayed-session' } }));
85
+ } else if (req.method === 'DELETE') {
86
+ deleted++; active = false; clearTimeout(expiry); res.end('{"value":null}');
87
+ } else if (!active) {
88
+ res.statusCode = 404; res.end('{"value":{"error":"invalid session id"}}');
89
+ } else {
90
+ renew();
91
+ if (req.url?.endsWith('/timeouts')) heartbeats++;
92
+ if (req.url?.endsWith('/press_keycode')) homeKeys++;
93
+ res.end('{"value":{}}');
94
+ }
95
+ });
96
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
97
+ process.env.APPIUM_SERVER_URL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
98
+ try {
99
+ mode = 'long-idle'; controller = new AbortController();
100
+ const result = await run([{ id: 'wait', type: 'delay', label: 'idle', timeoutMs: 450 }, { id: 'home', type: 'key', label: 'Home', keyCode: 3 }]);
101
+ assert.equal(result.success, true, result.output);
102
+ assert.equal(timeoutSeconds, 60); assert.ok(heartbeats > 3); assert.equal(homeKeys, 1); assert.equal(deleted, 1);
103
+ const heartbeatCount = heartbeats;
104
+ await new Promise(resolve => setTimeout(resolve, 40));
105
+ assert.equal(heartbeats, heartbeatCount, 'heartbeat stops after cleanup');
106
+ mode = 'creation-abort'; controller = new AbortController();
107
+ const created = new Promise<void>(resolve => { createdResolve = resolve; });
108
+ assert.equal((await run([actions[0]!])).stopped, true);
109
+ await created;
110
+ assert.equal(active, true, 'server can create a session after the client cancelled');
111
+ await new Promise(resolve => setTimeout(resolve, 220));
112
+ assert.equal(active, false, 'unclaimed external session has a finite idle lease');
113
+ } finally {
114
+ clearTimeout(expiry); server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve()));
115
+ }
116
+ } finally {
117
+ childProcess.execFile = original.execFile; globalThis.fetch = original.fetch; globalThis.setInterval = original.setInterval; syncBuiltinESMExports();
118
+ for (const key of ['ANDROID_MIDSCENE_DATA_ROOT', 'ANDROID_HOME', 'ANDROID_SDK_ROOT', 'APPIUM_SERVER_URL']) {
119
+ if (env[key] === undefined) delete process.env[key]; else process.env[key] = env[key];
120
+ }
121
+ await rm(root, { recursive: true, force: true });
122
+ }
123
+ });
@@ -1,3 +1,4 @@
1
+ import { readBody, requestErrorStatus } from '../request-body';
1
2
  import type { AiObservationConfig } from '../../src/appium-recorder/ai-recognition';
2
3
  import { execFile } from 'node:child_process';
3
4
  import { sendReplayVideo } from './video-response';
@@ -23,7 +24,7 @@ import {
23
24
  type AppiumRecordedStepRecord,
24
25
  } from './repository';
25
26
  import { clearAppDataOnDevice, launchAppOnDevice, replayAppiumScript } from './appium-runner';
26
- import { isRemoteDeviceId, sendRemoteCommand } from '../remote-agents/registry';
27
+ import { isRemoteDeviceId, sendRemoteCommand, stopRemoteReplay } from '../remote-agents/registry';
27
28
  import { getAdbCommand } from '../android-sdk';
28
29
  import { createAppiumScriptExport } from './script-export';
29
30
  import { resolveReportSummary } from '../../src/appium-recorder/report-summary';
@@ -43,21 +44,10 @@ function sendStreamEvent(res: ServerResponse, payload: unknown) {
43
44
  res.write(`${JSON.stringify(payload)}\n`);
44
45
  }
45
46
 
46
- async function readBody<T>(req: IncomingMessage) {
47
- let body = '';
48
- req.on('data', (chunk) => {
49
- body += chunk;
50
- });
51
- return await new Promise<T>((resolve) => {
52
- req.on('end', () => {
53
- resolve(JSON.parse(body || '{}') as T);
54
- });
55
- });
56
- }
57
47
 
58
48
  function execFileText(command: string, args: string[] = []) {
59
49
  return new Promise<string>((resolve, reject) => {
60
- execFile(command, args, { maxBuffer: 20 * 1024 * 1024 }, (error, stdout, stderr) => {
50
+ execFile(command, args, { maxBuffer: 20 * 1024 * 1024, timeout: 10000 }, (error, stdout, stderr) => {
61
51
  if (error) {
62
52
  reject(new Error(stderr || error.message));
63
53
  return;
@@ -386,6 +376,10 @@ export async function handleAppiumRecorderRequest(
386
376
  const parsed = await readBody<{ deviceId?: string }>(req);
387
377
  const deviceId = parsed.deviceId || selectedDeviceId;
388
378
  assertDeviceAllowed(deviceId);
379
+ if (isRemoteDeviceId(deviceId)) {
380
+ sendJson(res, { success: true, stopped: stopRemoteReplay(deviceId) });
381
+ return true;
382
+ }
389
383
  const controller = replayAbortControllers.get(deviceId);
390
384
  controller?.abort();
391
385
  sendJson(res, { success: true, stopped: Boolean(controller) });
@@ -444,10 +438,6 @@ export async function handleAppiumRecorderRequest(
444
438
  globalVariables: getPresetVariables(),
445
439
  linkedScripts: linkedScriptSnapshot(script),
446
440
  }) as Awaited<ReturnType<typeof replayAppiumScript>>;
447
- if (result.history) {
448
- try { saveRunHistory({ ...result.history, scriptId: script.id, deviceId }); }
449
- catch { result.output += '\n历史记录保存失败'; }
450
- }
451
441
  const { history: _history, ...response } = result;
452
442
  if (streamOutput) {
453
443
  result.output?.split(/\r?\n/).forEach((line) => sendStreamEvent(res, { type: 'log', line }));
@@ -504,7 +494,7 @@ export async function handleAppiumRecorderRequest(
504
494
  sendStreamEvent(res, { type: 'error', message });
505
495
  res.end();
506
496
  } else {
507
- sendJson(res, { message }, 500);
497
+ sendJson(res, { message }, requestErrorStatus(error));
508
498
  }
509
499
  return true;
510
500
  }
@@ -40,3 +40,14 @@ export function deleteRunHistory(scriptId: string, id: string) {
40
40
  }
41
41
  runSql(`PRAGMA secure_delete=ON; DELETE FROM appium_run_history WHERE script_id=${sqlString(scriptId)} AND id=${sqlString(id)};`);
42
42
  }
43
+
44
+ export function listRunAnalytics() {
45
+ ensureTable();
46
+ return querySql<{ scriptId: string; scriptName: string; startedAt: string; durationMs: number; status: RunSummary['status'] }>(`
47
+ SELECT script_id AS scriptId, started_at AS startedAt,
48
+ json_extract(summary, '$.scriptName') AS scriptName,
49
+ json_extract(summary, '$.durationMs') AS durationMs,
50
+ json_extract(summary, '$.status') AS status
51
+ FROM appium_run_history;
52
+ `);
53
+ }
@@ -0,0 +1,27 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import type { IncomingMessage } from 'node:http';
4
+ import { canManageConfig, publicConfig, resolveSavedModelKey } from './config-access';
5
+ import { SAVED_API_KEY } from '../src/config/credentials';
6
+ import type { AppConfig } from './config';
7
+
8
+ test('configuration access requires a loopback client and matching browser origin', () => {
9
+ const request = (address: string, origin?: string) => ({ socket: { remoteAddress: address }, headers: { host: 'localhost:5173', origin } }) as IncomingMessage;
10
+ assert.equal(canManageConfig(request('192.0.2.10')), false);
11
+ assert.equal(canManageConfig(request('127.0.0.1', 'https://foreign.example')), false);
12
+ assert.equal(canManageConfig(request('::1', 'http://localhost:5173')), true);
13
+ assert.equal(canManageConfig(request('::ffff:127.0.0.1')), true);
14
+ });
15
+
16
+ test('public config masks every key and server env without modifying stored values; masked keys cannot move to another endpoint', () => {
17
+ const model = { baseUrl: 'https://model.example/v1', apiKey: 'private-secret', name: 'model' };
18
+ const stored = { runtime: { androidSdkPath: '', reportOutputPath: '' }, midscene: { model: { ...model, provider: 'custom', family: 'gpt-5' }, env: { SECRET: 'secret-env' } }, scriptOptimizer: { model }, appium: { model, promptOptimizer: { model } } } as AppConfig;
19
+ const output = publicConfig(stored);
20
+ assert.equal(JSON.stringify(output).includes('private-secret'), false);
21
+ assert.deepEqual(output.midscene.env, {});
22
+ assert.equal(stored.midscene.model.apiKey, 'private-secret');
23
+ assert.equal(resolveSavedModelKey({ ...model, apiKey: SAVED_API_KEY }, model), 'private-secret');
24
+ assert.throws(() => resolveSavedModelKey({ ...model, baseUrl: 'https://other.example', apiKey: SAVED_API_KEY }, model), /重新输入/);
25
+ assert.throws(() => resolveSavedModelKey({ ...model, provider: 'codex', apiKey: SAVED_API_KEY }, model), /重新输入/);
26
+ assert.equal(resolveSavedModelKey({ ...model, apiKey: 'replacement' }, model), 'replacement');
27
+ });
@@ -0,0 +1,27 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import type { AppConfig } from './config';
3
+ import { SAVED_API_KEY } from '../src/config/credentials';
4
+
5
+ export function canManageConfig(req: IncomingMessage) {
6
+ if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress || '')) return false;
7
+ if (!req.headers.origin) return true;
8
+ try { return new URL(req.headers.origin).host === req.headers.host; } catch { return false; }
9
+ }
10
+
11
+ export function publicConfig(config: AppConfig): AppConfig {
12
+ const result = structuredClone(config);
13
+ for (const model of [result.midscene.model, result.scriptOptimizer.model, result.appium.model, result.appium.promptOptimizer?.model]) {
14
+ if (model?.apiKey) model.apiKey = SAVED_API_KEY;
15
+ }
16
+ // Runtime environment overrides are only consumed by the server.
17
+ result.midscene.env = {};
18
+ return result;
19
+ }
20
+
21
+ export function resolveSavedModelKey(model: { apiKey?: string; baseUrl?: string; provider?: string }, stored?: { apiKey: string; baseUrl: string; provider?: string }) {
22
+ if (model.apiKey !== SAVED_API_KEY) return model.apiKey || '';
23
+ if (!stored || model.baseUrl?.trim().replace(/\/+$/, '') !== stored.baseUrl.trim().replace(/\/+$/, '') || model.provider !== stored.provider) {
24
+ throw new Error('模型连接地址或提供方已改变,请重新输入 API Key');
25
+ }
26
+ return stored.apiKey;
27
+ }