@midscene/cli 1.16.2 → 1.16.5

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.
package/dist/es/index.mjs CHANGED
@@ -13,18 +13,18 @@ import { fileURLToPath as external_url_fileURLToPath } from "url";
13
13
  import node_process, { cwd as external_node_process_cwd } from "node:process";
14
14
  import lodash_merge from "lodash.merge";
15
15
  import { loadDotenvConfig, runModelCommand } from "@midscene/test/internal/model-command";
16
- import { createHash, randomBytes, randomUUID } from "node:crypto";
16
+ import { createHash, randomUUID } from "node:crypto";
17
17
  import { getMidsceneRunSubDir } from "@midscene/shared/common";
18
18
  import { buildChromeArgs, buildDownloadBehavior, defaultViewportHeight, defaultViewportWidth } from "@midscene/web/puppeteer-agent-launcher";
19
19
  import puppeteer from "puppeteer";
20
20
  import { createYamlPlayer as yaml_runtime_createYamlPlayer } from "@midscene/test/internal/yaml-runtime";
21
- import { stripVTControlCharacters } from "node:util";
21
+ import { inspect, stripVTControlCharacters } from "node:util";
22
22
  import { createRequire } from "node:module";
23
23
  import { serializeError } from "@midscene/shared/agent-tools/error-formatter";
24
24
  import { spawn } from "node:child_process";
25
+ import { SiwcError, createSiwcAuthorization, exchangeSiwcCode, parseSiwcCallback, refreshSiwcCredentials, withSiwcStage } from "@midscene/core/ai-model";
25
26
  import { homedir } from "node:os";
26
27
  import { createServer } from "node:http";
27
- import { createRemoteJWKSet, jwtVerify } from "jose";
28
28
  import { classifyWorkflowDocumentFormat } from "@midscene/core/internal/test-runner";
29
29
  import * as __rspack_external_assert from "assert";
30
30
  import * as __rspack_external_fs from "fs";
@@ -2801,7 +2801,7 @@ var __webpack_modules__ = {
2801
2801
  },
2802
2802
  "./src/index.ts" (__unused_rspack_module, __unused_rspack___webpack_exports__, __webpack_require__) {
2803
2803
  var package_namespaceObject = {
2804
- rE: "1.16.2"
2804
+ rE: "1.16.5"
2805
2805
  };
2806
2806
  var brace_expansion = __webpack_require__("../../node_modules/.pnpm/brace-expansion@2.0.1/node_modules/brace-expansion/index.js");
2807
2807
  const MAX_PATTERN_LENGTH = 65536;
@@ -8475,7 +8475,7 @@ Usage:
8475
8475
  type: 'boolean',
8476
8476
  description: 'Whether to dismiss the HarmonyOS keyboard after text input'
8477
8477
  }
8478
- }).version('version', 'Show version number', "1.16.2").help().epilogue(`For complete list of configuration options, please visit:
8478
+ }).version('version', 'Show version number', "1.16.5").help().epilogue(`For complete list of configuration options, please visit:
8479
8479
  • Web options: https://midscenejs.com/automate-with-scripts-in-yaml#the-web-part
8480
8480
  • Android options: https://midscenejs.com/automate-with-scripts-in-yaml#the-android-part
8481
8481
  • HarmonyOS options: https://midscenejs.com/automate-with-scripts-in-yaml#the-harmony-part
@@ -12490,19 +12490,31 @@ defineYamlBatchTest(test, testOptions);
12490
12490
  const success = printExecutionSummary(results, summaryPath);
12491
12491
  return success ? exitCode : 1;
12492
12492
  }
12493
+ function describeSiwcError(error) {
12494
+ return inspect(error, {
12495
+ depth: null,
12496
+ colors: false,
12497
+ maxArrayLength: null,
12498
+ maxStringLength: null
12499
+ });
12500
+ }
12493
12501
  const defaultCredentialsPath = ()=>join(homedir(), '.midscene', 'siwc.json');
12494
12502
  async function readCredentialsFile(path) {
12495
12503
  const content = await readFile(path, 'utf8').catch((error)=>{
12496
12504
  if ('ENOENT' === error.code) return;
12497
- throw new Error('Unable to read the local SIWC credentials file.');
12505
+ throw new SiwcError('Unable to read the local SIWC credentials file.', {
12506
+ cause: error
12507
+ });
12498
12508
  });
12499
12509
  if (void 0 === content) return;
12500
12510
  try {
12501
12511
  const value = JSON.parse(content);
12502
- if (!value || 'string' != typeof value.credentials?.ext_agent_host_id || !value.credentials.ext_agent_host_id) throw new Error('Invalid SIWC credentials record.');
12512
+ if (!value || 'string' != typeof value.credentials?.ext_agent_host_id || !value.credentials.ext_agent_host_id) throw new SiwcError('Invalid SIWC credentials record.');
12503
12513
  return value;
12504
- } catch {
12505
- throw new Error('Invalid local SIWC credentials file.');
12514
+ } catch (error) {
12515
+ throw new SiwcError('Invalid local SIWC credentials file.', {
12516
+ cause: error
12517
+ });
12506
12518
  }
12507
12519
  }
12508
12520
  async function readRegistration(path) {
@@ -12541,8 +12553,10 @@ defineYamlBatchTest(test, testOptions);
12541
12553
  mode: 448
12542
12554
  });
12543
12555
  } catch (error) {
12544
- if ('EEXIST' === error.code) throw new Error(`Another SIWC command holds ${lockPath}. If it was interrupted, remove this lock only after confirming that no SIWC command is running.`);
12545
- throw new Error('Unable to lock the local SIWC credentials file.');
12556
+ if ('EEXIST' === error.code) throw new SiwcError(`Another SIWC command holds ${lockPath}. If it was interrupted, remove this lock only after confirming that no SIWC command is running.`);
12557
+ throw new SiwcError('Unable to lock the local SIWC credentials file.', {
12558
+ cause: error
12559
+ });
12546
12560
  }
12547
12561
  return ()=>rm(lockPath, {
12548
12562
  recursive: true,
@@ -12557,18 +12571,13 @@ defineYamlBatchTest(test, testOptions);
12557
12571
  'access_token',
12558
12572
  'refresh_token',
12559
12573
  'id_token'
12560
- ].some((key)=>'string' != typeof value[key] || !value[key]) || !Array.isArray(value.scopes) || !value.scopes.every((scope)=>'string' == typeof scope) || !Number.isFinite(value.expires_at)) throw new Error('No valid SIWC credentials. Run midscene model siwc login first.');
12574
+ ].some((key)=>'string' != typeof value[key] || !value[key]) || !Array.isArray(value.scopes) || !value.scopes.every((scope)=>'string' == typeof scope) || !Number.isFinite(value.expires_at)) throw new SiwcError('No valid SIWC credentials. Run midscene model siwc login first.');
12561
12575
  return value;
12562
12576
  }
12563
- const ISSUER = 'https://auth.openai.com';
12564
- const RESOURCE = 'https://api.openai.com/v1';
12565
- const SCOPES = 'openid profile email offline_access resource.invoke chatgpt.tokens.use.direct';
12566
12577
  async function loginWithOpenAI(options, dependencies) {
12567
12578
  const hostId = options.hostId ?? `urn:uuid:${randomUUID()}`;
12568
12579
  const existingClientId = options.clientId;
12569
- const state = randomBytes(32).toString('base64url');
12570
- const nonce = randomBytes(32).toString('base64url');
12571
- const verifier = randomBytes(32).toString('base64url');
12580
+ const context = {};
12572
12581
  const signal = options.signal;
12573
12582
  signal.throwIfAborted();
12574
12583
  const server = createServer();
@@ -12582,17 +12591,24 @@ defineYamlBatchTest(test, testOptions);
12582
12591
  response.setHeader('Referrer-Policy', 'no-referrer');
12583
12592
  const url = new URL(request.url || '/', 'http://127.0.0.1');
12584
12593
  if ('GET' !== request.method || '/auth/callback' !== url.pathname) return void response.writeHead(404).end('Not found');
12585
- if (1 !== url.searchParams.getAll('state').length || url.searchParams.get('state') !== state) return void response.writeHead(400).end('Invalid login state. Use the original authorization link.');
12594
+ if (!context.attempt) return void response.writeHead(400).end('Authorization has not started.');
12586
12595
  if (consumed.value) return void response.writeHead(409).end('Authorization already received.');
12587
- if (url.searchParams.has('error')) {
12588
- consumed.value = true;
12589
- response.writeHead(400).end('Authorization was not granted. Return to the terminal.');
12590
- reject(new Error('OpenAI authorization was denied or failed.'));
12596
+ const parsed = (()=>{
12597
+ try {
12598
+ return parseSiwcCallback(new URL(request.url || '/', context.attempt.redirectUri).toString(), context.attempt);
12599
+ } catch (error) {
12600
+ response.writeHead(400).end('Invalid authorization callback.');
12601
+ if (url.searchParams.has('error')) {
12602
+ consumed.value = true;
12603
+ reject(error);
12604
+ }
12605
+ return;
12606
+ }
12607
+ })();
12608
+ if (!parsed) {
12609
+ if (!response.writableEnded) response.writeHead(400).end('Invalid login state. Use the original authorization link.');
12591
12610
  return;
12592
12611
  }
12593
- const code = url.searchParams.get('code');
12594
- const clientId = url.searchParams.get('client_id') || existingClientId;
12595
- if (!code || !clientId || 'dynamic_agent_client' === clientId || existingClientId && clientId !== existingClientId || 1 !== url.searchParams.getAll('code').length || url.searchParams.getAll('client_id').length > 1) return void response.writeHead(400).end('Invalid authorization callback.');
12596
12612
  consumed.value = true;
12597
12613
  response.setHeader('Content-Type', 'text/html; charset=utf-8');
12598
12614
  response.end(`<!doctype html>
@@ -12618,10 +12634,7 @@ defineYamlBatchTest(test, testOptions);
12618
12634
  </main>
12619
12635
  </body>
12620
12636
  </html>`);
12621
- resolve({
12622
- code,
12623
- clientId
12624
- });
12637
+ resolve(parsed);
12625
12638
  });
12626
12639
  server.on('error', reject);
12627
12640
  });
@@ -12640,132 +12653,22 @@ defineYamlBatchTest(test, testOptions);
12640
12653
  });
12641
12654
  signal.throwIfAborted();
12642
12655
  const redirectUri = `http://127.0.0.1:${server.address().port}/auth/callback`;
12643
- const authorization = new URL(`${ISSUER}/api/accounts/authorize`);
12644
- authorization.search = new URLSearchParams({
12645
- client_id: existingClientId ?? 'dynamic_agent_client',
12646
- ...existingClientId ? {} : {
12647
- agent_name_hint: 'Midscene'
12648
- },
12649
- ext_agent_host_id: hostId,
12650
- response_type: 'code',
12651
- redirect_uri: redirectUri,
12652
- scope: SCOPES,
12653
- resource: RESOURCE,
12654
- state,
12655
- nonce,
12656
- code_challenge_method: 'S256',
12657
- code_challenge: createHash('sha256').update(verifier).digest('base64url')
12658
- }).toString();
12659
- await options.onAuthorization(authorization.toString());
12660
- const { code, clientId } = await callback;
12661
- signal.throwIfAborted();
12662
- const request = dependencies?.fetch ?? fetch;
12663
- const result = await request(`${ISSUER}/api/accounts/oauth/token`, {
12664
- method: 'POST',
12665
- headers: {
12666
- 'Content-Type': 'application/x-www-form-urlencoded'
12667
- },
12668
- body: new URLSearchParams({
12669
- grant_type: 'authorization_code',
12670
- client_id: clientId,
12671
- code,
12672
- code_verifier: verifier,
12673
- redirect_uri: redirectUri,
12674
- resource: RESOURCE
12675
- }),
12676
- signal,
12677
- redirect: 'error'
12656
+ const { attempt, url } = await createSiwcAuthorization({
12657
+ hostId,
12658
+ clientId: existingClientId,
12659
+ redirectUri,
12660
+ agentName: 'Midscene'
12678
12661
  });
12679
- if (!result.ok) throw new Error(`OpenAI token exchange failed (HTTP ${result.status}). Run midscene model siwc login again.`);
12680
- const tokens = await result.json();
12681
- if (!tokens || 'string' != typeof tokens.access_token || !tokens.access_token || 'string' != typeof tokens.refresh_token || !tokens.refresh_token || 'string' != typeof tokens.id_token || 'string' != typeof tokens.scope || 'number' != typeof tokens.expires_in || !Number.isFinite(tokens.expires_in) || tokens.expires_in <= 0 || 'string' != typeof tokens.token_type || 'bearer' !== tokens.token_type.toLowerCase()) throw new Error('OpenAI returned an invalid token response.');
12682
- const scopes = tokens.scope.split(/\s+/);
12683
- if (!scopes.includes('chatgpt.tokens.use.direct')) throw new Error('ChatGPT plan usage was not authorized. Run midscene model siwc login again and grant access.');
12684
- const keys = dependencies?.keys ?? createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`));
12685
- const { payload } = await jwtVerify(tokens.id_token, keys, {
12686
- issuer: ISSUER,
12687
- audience: clientId,
12688
- algorithms: [
12689
- 'RS256'
12690
- ],
12691
- requiredClaims: [
12692
- 'sub',
12693
- 'exp',
12694
- 'iat',
12695
- 'nonce'
12696
- ]
12697
- });
12698
- if (payload.nonce !== nonce || !payload.sub) throw new Error('OpenAI login identity or nonce did not match.');
12699
- signal.throwIfAborted();
12700
- return {
12701
- ext_agent_host_id: hostId,
12702
- client_id: clientId,
12703
- subject: payload.sub,
12704
- issuer: ISSUER,
12705
- ...'string' == typeof payload.email ? {
12706
- email: payload.email
12707
- } : {},
12708
- access_token: tokens.access_token,
12709
- refresh_token: tokens.refresh_token,
12710
- id_token: tokens.id_token,
12711
- scopes,
12712
- expires_at: Date.now() + 1000 * tokens.expires_in
12713
- };
12662
+ context.attempt = attempt;
12663
+ await options.onAuthorization(url);
12664
+ const result = await withSiwcStage('Authorization callback', ()=>callback);
12665
+ return await exchangeSiwcCode(attempt, result, signal, dependencies);
12714
12666
  } finally{
12715
12667
  signal.removeEventListener('abort', abort);
12716
12668
  server.closeAllConnections();
12717
12669
  await new Promise((resolve)=>server.close(()=>resolve()));
12718
12670
  }
12719
12671
  }
12720
- async function refreshOpenAICredentials(saved, signal, dependencies) {
12721
- const issuer = 'https://auth.openai.com';
12722
- signal.throwIfAborted();
12723
- const response = await (dependencies?.fetch ?? fetch)(`${issuer}/api/accounts/oauth/token`, {
12724
- method: 'POST',
12725
- headers: {
12726
- 'Content-Type': 'application/x-www-form-urlencoded'
12727
- },
12728
- body: new URLSearchParams({
12729
- grant_type: 'refresh_token',
12730
- client_id: saved.client_id,
12731
- refresh_token: saved.refresh_token,
12732
- resource: 'https://api.openai.com/v1'
12733
- }),
12734
- signal,
12735
- redirect: 'error'
12736
- });
12737
- if (!response.ok) throw new Error('OpenAI refresh failed.');
12738
- const tokens = await response.json();
12739
- if (!tokens || 'string' != typeof tokens.access_token || !tokens.access_token || 'string' != typeof tokens.refresh_token || !tokens.refresh_token || 'number' != typeof tokens.expires_in || !Number.isFinite(tokens.expires_in) || tokens.expires_in <= 0 || 'string' != typeof tokens.token_type || 'bearer' !== tokens.token_type.toLowerCase() || 'string' != typeof tokens.scope) throw new Error('OpenAI returned an invalid refresh response.');
12740
- const scopes = tokens.scope.split(/\s+/);
12741
- if (!scopes.includes('chatgpt.tokens.use.direct')) throw new Error('ChatGPT plan usage is no longer authorized.');
12742
- if (void 0 !== tokens.id_token) {
12743
- if ('string' != typeof tokens.id_token || !tokens.id_token) throw new Error('Invalid refreshed ID token.');
12744
- const keys = dependencies?.keys ?? createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
12745
- const { payload } = await jwtVerify(tokens.id_token, keys, {
12746
- issuer,
12747
- audience: saved.client_id,
12748
- algorithms: [
12749
- 'RS256'
12750
- ],
12751
- requiredClaims: [
12752
- 'sub',
12753
- 'exp',
12754
- 'iat'
12755
- ]
12756
- });
12757
- if (payload.sub !== saved.subject) throw new Error('Refreshed identity does not match the saved account.');
12758
- }
12759
- signal.throwIfAborted();
12760
- return {
12761
- ...saved,
12762
- access_token: tokens.access_token,
12763
- refresh_token: tokens.refresh_token,
12764
- id_token: tokens.id_token ?? saved.id_token,
12765
- scopes,
12766
- expires_at: Date.now() + 1000 * tokens.expires_in
12767
- };
12768
- }
12769
12672
  const USAGE = `Usage:
12770
12673
  midscene model siwc login [--no-open] [--port <port>]
12771
12674
  midscene model siwc refresh
@@ -12838,7 +12741,7 @@ Options (login):
12838
12741
  }
12839
12742
  }
12840
12743
  const unlock = await lockCredentials(credentialsPath).catch((error)=>{
12841
- io.stderr(error.message);
12744
+ io.stderr(describeSiwcError(error));
12842
12745
  });
12843
12746
  if (!unlock) return 1;
12844
12747
  const controller = new AbortController();
@@ -12848,31 +12751,31 @@ Options (login):
12848
12751
  process.once('SIGTERM', cancel);
12849
12752
  try {
12850
12753
  io.stderr(source_default().cyan.bold(`🔄 Starting OpenAI SIWC ${action}...`));
12851
- const saved = await readRegistration(credentialsPath);
12754
+ const saved = await withSiwcStage('Read credentials', ()=>readRegistration(credentialsPath));
12852
12755
  const credentials = await (async ()=>{
12853
- if ('refresh' === action) return refreshOpenAICredentials(requireCredentials(saved), controller.signal);
12756
+ if ('refresh' === action) return withSiwcStage('Refresh', ()=>refreshSiwcCredentials(requireCredentials(saved), controller.signal));
12854
12757
  const registration = saved ?? {
12855
12758
  ext_agent_host_id: `urn:uuid:${randomUUID()}`
12856
12759
  };
12857
- if (!saved) await saveRegistration(credentialsPath, registration);
12858
- const result = await loginWithOpenAI({
12859
- port: portOptions.port,
12860
- hostId: registration.ext_agent_host_id,
12861
- clientId: registration.client_id,
12862
- signal: controller.signal,
12863
- onAuthorization: async (url)=>{
12864
- io.stderr(`\n${source_default().bold('Continue with ChatGPT:')}\n${url}\n\n${source_default().gray('Waiting for authorization...')}`);
12865
- if (!portOptions.noOpen) try {
12866
- await openBrowser(url);
12867
- } catch {
12868
- io.stderr('Open the URL above in your browser to continue.');
12760
+ if (!saved) await withSiwcStage('Save registration', ()=>saveRegistration(credentialsPath, registration));
12761
+ const result = await withSiwcStage('Login', ()=>loginWithOpenAI({
12762
+ port: portOptions.port,
12763
+ hostId: registration.ext_agent_host_id,
12764
+ clientId: registration.client_id,
12765
+ signal: controller.signal,
12766
+ onAuthorization: async (url)=>{
12767
+ io.stderr(`\n${source_default().bold('Continue with ChatGPT:')}\n${url}\n\n${source_default().gray('Waiting for authorization...')}`);
12768
+ if (!portOptions.noOpen) try {
12769
+ await openBrowser(url);
12770
+ } catch {
12771
+ io.stderr('Open the URL above in your browser to continue.');
12772
+ }
12869
12773
  }
12870
- }
12871
- });
12872
- if (registration.subject && result.subject !== registration.subject) throw new Error('Login identity does not match the saved account.');
12774
+ }));
12775
+ if (registration.subject && result.subject !== registration.subject) throw new SiwcError('Login identity does not match the saved account.');
12873
12776
  return result;
12874
12777
  })();
12875
- await saveRegistration(credentialsPath, credentials);
12778
+ await withSiwcStage('Save credentials', ()=>saveRegistration(credentialsPath, credentials));
12876
12779
  io.stderr('\n🔑 your Access token:');
12877
12780
  io.stdout(credentials.access_token);
12878
12781
  const expiresAt = new Intl.DateTimeFormat('en-GB', {
@@ -12886,8 +12789,8 @@ Options (login):
12886
12789
  io.stderr(`\nExpires at: ${expiresAt} (${expiresIn})`);
12887
12790
  io.stderr(`\n${source_default().green(`✅ SIWC ${action} succeeded.`)}\n\nYou can use this access token as your model API Key in Midscene by setting MIDSCENE_MODEL_API_KEY.\n\nFull credentials are saved locally to:\n${source_default().cyan(credentialsPath)}\n\n${source_default().gray('Midscene does not collect your credentials.')}`);
12888
12791
  return 0;
12889
- } catch {
12890
- io.stderr(source_default().red(controller.signal.aborted ? `OpenAI ${action} cancelled or timed out.` : `OpenAI ${action} failed. Check the local credentials file permissions or run midscene model siwc login to authorize again.`));
12792
+ } catch (error) {
12793
+ io.stderr(source_default().red(controller.signal.aborted ? `OpenAI ${action} cancelled or timed out.\n${describeSiwcError(error)}` : `OpenAI ${action} failed.\n${describeSiwcError(error)}`));
12891
12794
  return 1;
12892
12795
  } finally{
12893
12796
  clearTimeout(timer);