openyida 2026.10.29 → 2026.10.30

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 (64) hide show
  1. package/README.md +14 -2
  2. package/bin/yida-beta.js +5 -0
  3. package/bin/yida.js +20 -0
  4. package/lib/agent/provider-preflight.js +5 -3
  5. package/lib/app/custom-theme.js +21 -1
  6. package/lib/app/services/canvas-page-schema-builder.js +4 -0
  7. package/lib/app/update-app.js +47 -19
  8. package/lib/auth/org.js +14 -3
  9. package/lib/auth/qwenwork-client.js +190 -0
  10. package/lib/auth/qwenwork-cloud.js +128 -0
  11. package/lib/auth/qwenwork-desktop.js +157 -0
  12. package/lib/auth/qwenwork-login.js +149 -0
  13. package/lib/auth/qwenwork.js +37 -0
  14. package/lib/auth/session-identity.js +23 -0
  15. package/lib/auth/token-auth.js +25 -4
  16. package/lib/auth/token-store.js +4 -0
  17. package/lib/core/agent-capabilities.js +63 -5
  18. package/lib/core/browser-handoff.js +18 -4
  19. package/lib/core/command-manifest.js +13 -8
  20. package/lib/core/locales/en.js +20 -3
  21. package/lib/core/locales/zh.js +19 -2
  22. package/lib/core/qwenwork-login-guidance.js +35 -0
  23. package/lib/core/utils.js +4 -0
  24. package/lib/design-plan/visual-policy.js +1 -1
  25. package/package.json +1 -1
  26. package/scripts/postinstall.js +3 -1
  27. package/yida-skills/SKILL.md +18 -4
  28. package/yida-skills/references/execution-rules.md +17 -0
  29. package/yida-skills/references/qwenwork-cloud-login.md +22 -0
  30. package/yida-skills/references/qwenwork-desktop-login.md +12 -0
  31. package/yida-skills/references/setup-and-env.md +14 -3
  32. package/yida-skills/references/source-repair.md +2 -2
  33. package/yida-skills/references/user-facing-summary.md +45 -0
  34. package/yida-skills/skills/yida-app/SKILL.md +1 -1
  35. package/yida-skills/skills/yida-app/workflow/step-3-create-or-reuse-app.md +2 -2
  36. package/yida-skills/skills/yida-app/workflow/step-7-page-code.md +1 -1
  37. package/yida-skills/skills/yida-app/workflow/step-8-publish-navigation.md +4 -0
  38. package/yida-skills/skills/yida-app/workflow/step-9-output-finish.md +20 -16
  39. package/yida-skills/skills/yida-canvas-custom-page/SKILL.md +4 -2
  40. package/yida-skills/skills/yida-canvas-custom-page/references/data-bridge-guide.md +25 -22
  41. package/yida-skills/skills/yida-canvas-custom-page/references/waf-safe-authoring.md +14 -37
  42. package/yida-skills/skills/yida-canvas-data-binding/SKILL.md +3 -17
  43. package/yida-skills/skills/yida-canvas-upgrade/SKILL.md +6 -4
  44. package/yida-skills/skills/yida-canvas-upgrade/references/migration-examples.md +1 -1
  45. package/yida-skills/skills/yida-create-app/SKILL.md +8 -6
  46. package/yida-skills/skills/yida-custom-page/SKILL.md +4 -2
  47. package/yida-skills/skills/yida-design/SKILL.md +1 -1
  48. package/yida-skills/skills/yida-design/references/application-style-library.md +3 -1
  49. package/yida-skills/skills/yida-design/references/application-theme-consistency.md +4 -4
  50. package/yida-skills/skills/yida-design/references/ask-human-interaction-contract.md +5 -1
  51. package/yida-skills/skills/yida-design/references/theme-selection.md +7 -6
  52. package/yida-skills/skills/yida-design/sub_skill/page-design/SKILL.md +2 -0
  53. package/yida-skills/skills/yida-design/sub_skill/ui-improve/SKILL.md +3 -1
  54. package/yida-skills/skills/yida-design/templates/design-themes/dark-rail-fine-lines/app_theme.css +10 -9
  55. package/yida-skills/skills/yida-design/templates/design-themes/dark-rail-fine-lines/design.md +6 -3
  56. package/yida-skills/skills/yida-design/workflow/output-design.md +1 -1
  57. package/yida-skills/skills/yida-design/workflow/step-2-theme-system.md +3 -3
  58. package/yida-skills/skills/yida-dingtalk-openapi/references/connector-mapping.md +7 -3
  59. package/yida-skills/skills/yida-login/SKILL.md +24 -1
  60. package/yida-skills/skills/yida-openyida-publish-guard/SKILL.md +2 -0
  61. package/yida-skills/skills/yida-publish-page/SKILL.md +11 -7
  62. package/yida-skills/skills/yida-rechart/SKILL.md +1 -1
  63. package/yida-skills/skills/yida-upgrade-app-theme/SKILL.md +2 -0
  64. package/yida-skills/skills-index.json +4 -4
package/README.md CHANGED
@@ -143,6 +143,18 @@ openyida login --check-only --json
143
143
  openyida auth status
144
144
  ```
145
145
 
146
+ In QwenWork web, `openyida login --qwenwork --json` (or ordinary `login`) reuses the sandbox identity. If cloud authentication fails, the same command falls back to OAuth; follow its authorization URL and wait for completion. Desktop and other hosts keep their existing login paths.
147
+
148
+ Login supports DingTalk and QwenWork organizations, ODS and personal spaces. Sessions preserve server-provided identity classification, business domain and refresh credentials. Historical sessions default to DingTalk organizations. JSON errors include bounded diagnostics without credentials or response bodies.
149
+
150
+ For QwenWork cloud identity diagnostics, run inside the actual cloud execution environment:
151
+
152
+ ```bash
153
+ openyida auth qwenwork user-info --json
154
+ ```
155
+
156
+ This legacy diagnostic reads the injected host credentials and reports identity and scope without printing or saving tokens. It does not create or validate a YiDA session; QwenWork organization IDs are not YiDA `corpId` values. Check the YiDA session with `openyida login --check-only --json`. See [QwenWork login guidance](yida-skills/references/qwenwork-cloud-login.md) for authentication behavior.
157
+
146
158
  To reuse an existing shared login profile for the current project, switch first instead of starting a new OAuth login:
147
159
 
148
160
  ```bash
@@ -457,9 +469,9 @@ When preparing a PR that needs a personal Fork, or when you ask how to support t
457
469
 
458
470
  | Command | Description |
459
471
  |---------|-------------|
460
- | `openyida login [target-url] [--env <name>\|--intl\|--overseas\|--global\|--yidaapps\|--alibaba] [--client-id <clientId>] [--endpoint <url>] [--no-browser]` | Login with OAuth token mode |
472
+ | `openyida login [--qwenwork] [target-url] [--env <name>\|--intl\|--overseas\|--global\|--yidaapps\|--alibaba] [--client-id <clientId>] [--endpoint <url>] [--no-browser]` | Login with OAuth token mode |
461
473
  | `openyida logout` | Logout / unbind current project auth |
462
- | `openyida auth <status\|login\|refresh\|logout\|profiles\|profile switch>` | Token login state and profile management |
474
+ | `openyida auth <status\|login\|refresh\|logout\|profiles\|profile switch\|qwenwork user-info> [--json]` | Token login state and profile management |
463
475
  | `openyida org <list\|switch> [--json] [--corp-id <corpId>]` | Organization management (list / switch existing profiles first) |
464
476
  | `openyida env [--json\|setup\|list\|show\|switch\|add\|remove] [options]` | Detect AI tool environment & token login state |
465
477
 
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // beta 保持当前安装版本;环境、域名和登录态沿用标准 CLI 的解析规则。
4
+ process.env.OPENYIDA_SKIP_UPDATE_CHECK = '1';
5
+ require('./yida.js');
package/bin/yida.js CHANGED
@@ -598,7 +598,23 @@ async function main() {
598
598
  });
599
599
  assertManagedInvocation(command, rawArgs);
600
600
  applyQuietFlag();
601
+ // 一次性票据只在当前进程消费,不能先自更新并重启命令。
602
+ switch (command) {
603
+ case 'login-qwenwork':
604
+ await require('../lib/auth/qwenwork-desktop').run(args);
605
+ return;
606
+ }
601
607
  applyGlobalEnvironmentFlags();
608
+ // 网页态由 CLI 消费宿主凭据;桌面态继续消费宿主准备的登录态。
609
+ const webLogin = (command === 'login' || (command === 'auth' && args[0] === 'login')) &&
610
+ require('../lib/core/qwenwork-login-guidance').isQwenWorkWebRuntime(require('../lib/core/utils').detectRuntimeCapabilities());
611
+ if ((command === 'login' && args.includes('--qwenwork')) || webLogin) {
612
+ const loginArgs = applyLoginEnvironmentFlags(command === 'auth' ? args.slice(1) : args, { inferTargetUrl: true });
613
+ if (hasHelpFlag(loginArgs)) { printLoginHelp(); return; }
614
+ assertNoUnsupportedLegacyLoginFlags(rawArgs, loginArgs);
615
+ await require('../lib/auth/qwenwork-login').run(loginArgs, buildTokenLoginOptions(loginArgs));
616
+ return;
617
+ }
602
618
 
603
619
  const { maybeAutoUpdate } = require('../lib/core/update');
604
620
  const updateResult = await maybeAutoUpdate({
@@ -718,6 +734,10 @@ async function main() {
718
734
 
719
735
  case 'auth': {
720
736
  const subCommand = args[0];
737
+ if (subCommand === 'qwenwork') {
738
+ await require('../lib/auth/qwenwork').run(args.slice(1));
739
+ break;
740
+ }
721
741
  const authArgs = applyLoginEnvironmentFlags(args.slice(1), { inferTargetUrl: true });
722
742
  const { getAuthStatus } = require('../lib/core/utils');
723
743
  const { tokenLogin, tokenLogout, tokenRefresh } = require('../lib/auth/token-auth');
@@ -139,13 +139,15 @@ function probe(executable, args, rpc, options = {}) {
139
139
  child = (options.probeSpawn || spawn)(command.executable, command.args, {
140
140
  env, cwd: os.tmpdir(), shell: false, windowsHide: true,
141
141
  windowsVerbatimArguments: command.windowsVerbatimArguments,
142
- detached: platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'],
142
+ // Finite Windows probes do not consume input. Avoid an unused redirected
143
+ // stdin pipe during PowerShell startup; ACP still needs its live pipe.
144
+ detached: platform !== 'win32', stdio: [platform === 'win32' && !rpc ? 'ignore' : 'pipe', 'pipe', 'pipe'],
143
145
  });
144
146
  signals.once('SIGINT', interrupted);
145
147
  signals.once('SIGTERM', interrupted);
146
148
  timer = setTimeout(() => { stop(); finish({ status: 'probe_timeout' }); }, options.timeoutMs || 15000);
147
149
  child.on('error', () => finish({ status: 'launch_failed' }));
148
- child.stdin.on('error', () => {});
150
+ if (child.stdin) { child.stdin.on('error', () => {}); }
149
151
  child.stderr.on('data', chunk => { output = (output + chunk.toString()).slice(-65536); });
150
152
  child.stdout.on('data', chunk => {
151
153
  output = (output + chunk.toString()).slice(-65536);
@@ -171,7 +173,7 @@ function probe(executable, args, rpc, options = {}) {
171
173
  ? { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: 'openyida-preflight', version: '1' } }
172
174
  : { clientInfo: { name: 'openyida-preflight', version: '1' }, capabilities: {} };
173
175
  child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params }) + '\n');
174
- } else { child.stdin.end(); }
176
+ } else if (child.stdin) { child.stdin.end(); }
175
177
  } catch { finish({ status: 'launch_failed' }); }
176
178
  });
177
179
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { createHash } = require('crypto');
5
6
  const { resolveBearerAuthHeaders } = require('../core/utils');
6
7
  const { readManagedContext, managedError } = require('../agent/managed-context');
7
8
  const { CliError } = require('../core/cli-error');
@@ -306,7 +307,26 @@ async function uploadCustomThemeFile(appType, filePath, authRef, options = {}) {
306
307
  if (!response.ok) {
307
308
  throw new Error((payload && (payload.errorMsg || payload.message)) || `主题文件上传失败: HTTP ${response.status}`);
308
309
  }
309
- return payload;
310
+ // Hash multipart bytes, not reread files or server-supplied receipt fields.
311
+ if (payload?.success === false) {
312
+ return { ...payload, themeUpload: { requested: true, status: 'rejected', uploadedThisRun: false, appType } };
313
+ }
314
+ const upload = unwrapUploadResponse(payload);
315
+ return { ...payload, themeUpload: {
316
+ requested: true,
317
+ status: 'uploaded',
318
+ uploadedThisRun: true,
319
+ appType,
320
+ baseUrl: new URL(authRef.baseUrl).origin,
321
+ corpId: authRef.corpId || null,
322
+ sourceFileName: file.name,
323
+ sourceBytes: file.content.length,
324
+ sourceSha256: createHash('sha256').update(file.content).digest('hex'),
325
+ themeColor: file.themeColor,
326
+ cssUrl: upload.downloadUrl || upload.url,
327
+ cssFileName: upload.name || '',
328
+ resourceContentVerified: false,
329
+ } };
310
330
  }
311
331
 
312
332
  module.exports = {
@@ -81,6 +81,10 @@ var OPENYIDA_UTIL_API_METHODS = [
81
81
  function openyidaYidaApiBridgeTargets() {
82
82
  var targets = [];
83
83
  function pushTarget(target) {
84
+ if (target !== window) {
85
+ var origin = window.location.origin;
86
+ if (!origin || origin === 'null' || target.location.origin !== origin) { return; }
87
+ }
84
88
  if (target && targets.indexOf(target) === -1) {
85
89
  targets.push(target);
86
90
  }
@@ -566,6 +566,25 @@ function themeSettingsMatch(expected, actual) {
566
566
  ['cssUrl', 'cssFileName'].every((key) => (expectedStyle[key] || '') === (actualStyle[key] || ''));
567
567
  }
568
568
 
569
+ function themeReadbackEvidence(params, details) {
570
+ const operation = params.disableCustomTheme ? 'disable'
571
+ : params.customThemeStyle ? 'bind_resource'
572
+ : params.colour || params.themeColor ? 'update_theme' : 'preserve';
573
+ return { ...details, scope: 'configuration_readback', operation, runtimeVerified: false };
574
+ }
575
+
576
+ function noThemeUpload(appType) {
577
+ return { requested: false, status: 'not_requested', uploadedThisRun: false, appType };
578
+ }
579
+
580
+ function themeFailureEvidence(themeUpload) {
581
+ const uncertain = themeUpload.uploadedThisRun === true || themeUpload.status === 'unknown';
582
+ return { themeUpload, runtimeVerification: { verified: false, status: 'not_checked' },
583
+ ...(uncertain ? { target: { appType: themeUpload.appType }, retrySafe: false,
584
+ sideEffectState: 'unknown', readbackAllowed: true } : {}),
585
+ };
586
+ }
587
+
569
588
  function getRequestedNavigationSettings(params, payload) {
570
589
  return Object.fromEntries(['navTheme', 'layoutDirection', 'hideAppNav', 'logoSource']
571
590
  .filter((name) => params[name] !== undefined && params[name] !== null && params[name] !== '')
@@ -631,7 +650,7 @@ async function saveAppSettings(params, authRef) {
631
650
  if (themeVerified && navigationVerified) {
632
651
  return { ...response,
633
652
  ...(submittedFields ? { submittedFields } : {}),
634
- ...(verifyTheme ? { themeVerification: { verified: true, ...actual } } : {}),
653
+ ...(verifyTheme ? { themeVerification: themeReadbackEvidence(params, { verified: true, ...actual }) } : {}),
635
654
  ...(navigationFields.length ? { navigationVerification: { verified: true, ...actualNavigation } } : {}),
636
655
  };
637
656
  }
@@ -646,8 +665,8 @@ async function saveAppSettings(params, authRef) {
646
665
  ...(submittedFields ? { submittedFields } : {}),
647
666
  errorCode: themeVerified ? 'APP_NAVIGATION_NOT_PERSISTED' : 'APP_THEME_NOT_PERSISTED',
648
667
  errorMsg: t(themeVerified ? 'update_app.navigation_not_persisted' : 'update_app.theme_not_persisted'),
649
- ...(verifyTheme ? { themeVerification: themeVerified
650
- ? { verified: true, ...actual } : { verified: false, expected, actual, readbackError } } : {}),
668
+ ...(verifyTheme ? { themeVerification: themeReadbackEvidence(params, themeVerified
669
+ ? { verified: true, ...actual } : { verified: false, expected, actual, readbackError }) } : {}),
651
670
  ...(navigationFields.length ? { navigationVerification: navigationVerified
652
671
  ? { verified: true, ...actualNavigation }
653
672
  : { verified: false, expected: expectedNavigation, actual: actualNavigation, readbackError } } : {}),
@@ -663,22 +682,25 @@ async function applyCustomThemeUpdate(appType, themeParams, authRef) {
663
682
  assertNavTheme(params.navTheme);
664
683
  assertLogoSource(params.logoSource);
665
684
 
666
- assertCustomThemeOptions(params);
667
- await checkAppStyleInjection(params, authRef);
668
- const themeFile = readThemeCssFile(params.themeFile);
669
- params.themeColor = themeFile.themeColor;
670
- const uploadResponse = await requestWithAutoLogin(
671
- (auth) => uploadCustomThemeFile(appType, params.themeFile, auth),
672
- authRef
673
- );
674
- params.customThemeStyle = buildCustomThemeStyle(uploadResponse);
675
-
685
+ let themeUpload = { requested: true, status: 'not_started', uploadedThisRun: false, appType };
676
686
  try {
677
- return await saveAppSettings(params, authRef);
687
+ assertCustomThemeOptions(params);
688
+ await checkAppStyleInjection(params, authRef);
689
+ readThemeCssFile(params.themeFile);
690
+ // Lost responses leave upload state unknown.
691
+ themeUpload = { ...themeUpload, status: 'unknown', uploadedThisRun: null };
692
+ const uploadResponse = await requestWithAutoLogin(
693
+ (auth) => uploadCustomThemeFile(appType, params.themeFile, auth),
694
+ authRef
695
+ );
696
+ if (uploadResponse.themeUpload) {themeUpload = uploadResponse.themeUpload;}
697
+ params.customThemeStyle = buildCustomThemeStyle(uploadResponse);
698
+ params.themeColor = themeUpload.themeColor;
699
+ return { ...await saveAppSettings(params, authRef), themeUpload };
678
700
  } catch (error) {
679
- error.details = { ...error.details, appType, uploadedResource: params.customThemeStyle,
680
- applicationSettingsVerified: false };
681
- throw error;
701
+ throwCommandError(error.message, { code: error.code || 'APP_THEME_UPDATE_FAILED', exitCode: error.exitCode,
702
+ details: { ...error.details, appType, uploadedResource: params.customThemeStyle,
703
+ ...themeFailureEvidence(themeUpload), applicationSettingsVerified: false } });
682
704
  }
683
705
  }
684
706
 
@@ -767,6 +789,8 @@ async function run(args) {
767
789
  postData
768
790
  );
769
791
  }, authRef);
792
+ const themeUpload = params.themeFile ? response?.themeUpload : noThemeUpload(params.appType);
793
+ if (themeUpload?.uploadedThisRun) {params.themeColor = themeUpload.themeColor;}
770
794
 
771
795
  // Verify the persisted resource before exposing success or a mutation receipt.
772
796
  let nameVerification;
@@ -789,9 +813,10 @@ async function run(args) {
789
813
  success: true,
790
814
  appType: params.appType,
791
815
  themeVerification: response.themeVerification,
816
+ themeUpload,
792
817
  nameVerification,
793
818
  navigationVerification: response.navigationVerification,
794
- ...(params.disableCustomTheme ? { runtimeVerification: { verified: false } } : {}),
819
+ runtimeVerification: { verified: false, status: 'not_checked' },
795
820
  updatedFields: {
796
821
  name: params.name || undefined,
797
822
  desc: params.desc || undefined,
@@ -810,11 +835,14 @@ async function run(args) {
810
835
  const errorMsg = response ? response.errorMsg || t('common.unknown_error') : t('common.request_failed');
811
836
  chalkResult(false, t('update_app.failed', errorMsg));
812
837
  console.log(JSON.stringify({ success: false, appType: params.appType, error: errorMsg,
838
+ themeUpload,
839
+ runtimeVerification: { verified: false, status: 'not_checked' },
813
840
  submittedFields: response && response.submittedFields,
814
841
  errorCode: response && response.errorCode, themeVerification: response && response.themeVerification,
815
842
  navigationVerification: response && response.navigationVerification }));
816
843
  throwCommandError(errorMsg, { code: response && response.errorCode,
817
- details: response && (response.errorCode === 'APP_NAVIGATION_NOT_PERSISTED' ? response.navigationVerification : response.themeVerification) });
844
+ details: response && { ...(response.errorCode === 'APP_NAVIGATION_NOT_PERSISTED' ? response.navigationVerification : response.themeVerification),
845
+ ...themeFailureEvidence(themeUpload) } });
818
846
  }
819
847
  }
820
848
 
package/lib/auth/org.js CHANGED
@@ -19,6 +19,10 @@ const {
19
19
  saveTokenSession,
20
20
  } = require('./token-store');
21
21
  const { tokenLogin } = require('./token-auth');
22
+ const { sessionIdentity } = require('./session-identity');
23
+ const { detectRuntimeCapabilities } = require('../core/utils');
24
+ const { isQwenWorkWebRuntime } = require('../core/qwenwork-login-guidance');
25
+ const { t } = require('../core/i18n');
22
26
 
23
27
  const LIST_CORP_INFOS_PATH = '/query/userservice/listCorpInfos.json';
24
28
 
@@ -146,6 +150,7 @@ function buildSwitchResult(status, currentSession, nextSession, message, extra =
146
150
  auth_mode: 'token',
147
151
  status,
148
152
  can_auto_use: true,
153
+ ...sessionIdentity(nextSession),
149
154
  previous_corp_id: currentSession && currentSession.corp_id,
150
155
  corp_id: nextSession.corp_id,
151
156
  corp_name: nextSession.corp_name,
@@ -191,6 +196,7 @@ async function switchOrganization(targetCorpId, options = {}) {
191
196
  ok: true,
192
197
  auth_mode: 'token',
193
198
  status: 'already_in_org',
199
+ ...sessionIdentity(currentSession),
194
200
  can_auto_use: true,
195
201
  corp_id: currentSession.corp_id,
196
202
  corp_name: currentSession.corp_name,
@@ -231,7 +237,12 @@ async function switchOrganization(targetCorpId, options = {}) {
231
237
  corpId: targetCorpId,
232
238
  };
233
239
 
234
- const login = options.tokenLogin || tokenLogin;
240
+ const cloud = isQwenWorkWebRuntime(options.runtime || detectRuntimeCapabilities({ env: options.env || process.env }));
241
+ const login = options.tokenLogin || (cloud ? async opts => {
242
+ const { ensureQwenWorkLogin } = require('./qwenwork-login');
243
+ await ensureQwenWorkLogin(opts);
244
+ return loadTokenSession(opts);
245
+ } : tokenLogin);
235
246
  const temporaryLogin = createTemporaryLoginOptions(loginOptions);
236
247
  let loginResult;
237
248
  try {
@@ -266,8 +277,8 @@ async function switchOrganization(targetCorpId, options = {}) {
266
277
  'switched',
267
278
  currentSession,
268
279
  savedSession,
269
- 'switched organization by OAuth login',
270
- { switch_source: 'oauth_login' }
280
+ cloud ? t('qwenwork.organization_switch_success') : 'switched organization by OAuth login',
281
+ { switch_source: cloud ? 'qwenwork_cloud' : 'oauth_login' }
271
282
  );
272
283
  }
273
284
 
@@ -0,0 +1,190 @@
1
+ 'use strict';
2
+
3
+ const { CliError } = require('../core/cli-error');
4
+ const { t } = require('../core/i18n');
5
+
6
+ const DEFAULT_TIMEOUT_MS = 20000;
7
+ const MAX_RESPONSE_BYTES = 1024 * 1024;
8
+
9
+ function failure(code, message, stage, details = {}) {
10
+ return new CliError(t(`qwenwork.${message}`), {
11
+ code: `QWENWORK_${code}`,
12
+ details: { stage, ...details },
13
+ });
14
+ }
15
+
16
+ function pick(env, names) {
17
+ for (const name of names) {
18
+ const value = String(env[name] || '').trim();
19
+ if (value) {
20
+ return { name, value };
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+
26
+ function normalizeBaseUrl(value) {
27
+ try {
28
+ const url = new URL(value);
29
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
30
+ if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) ||
31
+ url.username || url.password || url.search || url.hash) {
32
+ throw new Error('invalid_url');
33
+ }
34
+ return url.href.replace(/\/+$/, '');
35
+ } catch {
36
+ throw failure('INVALID_GATEWAY', 'invalid_url', 'resolve_credentials');
37
+ }
38
+ }
39
+
40
+ function isObject(value) {
41
+ return !!value && typeof value === 'object' && !Array.isArray(value);
42
+ }
43
+
44
+ // Do not include upstream bodies, headers or exception messages in diagnostics:
45
+ // an authentication endpoint may echo credentials even in an error response.
46
+ async function requestJson(method, url, accessToken, options, stage) {
47
+ const timeoutMs = options.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : options.timeoutMs;
48
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
49
+ throw failure('INVALID_TIMEOUT', 'request_failed', stage);
50
+ }
51
+ const controller = new globalThis.AbortController();
52
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
53
+ try {
54
+ const response = await globalThis.fetch(url, {
55
+ method,
56
+ headers: { accept: 'application/json', authorization: `Bearer ${accessToken}` },
57
+ redirect: 'manual',
58
+ signal: controller.signal,
59
+ });
60
+ if (!response.ok) {
61
+ if (response.body) {
62
+ await response.body.cancel();
63
+ }
64
+ throw failure('HTTP_ERROR', 'request_failed', stage, { status_code: response.status });
65
+ }
66
+ const chunks = [];
67
+ let size = 0;
68
+ if (response.body) {
69
+ const reader = response.body.getReader();
70
+ let next = await reader.read();
71
+ while (!next.done) {
72
+ const { value } = next;
73
+ size += value.byteLength;
74
+ if (size > MAX_RESPONSE_BYTES) {
75
+ await reader.cancel();
76
+ throw failure('RESPONSE_TOO_LARGE', 'invalid_response', stage);
77
+ }
78
+ chunks.push(Buffer.from(value));
79
+ next = await reader.read();
80
+ }
81
+ }
82
+ let payload;
83
+ try {
84
+ payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
85
+ } catch {
86
+ throw failure('INVALID_RESPONSE', 'invalid_response', stage);
87
+ }
88
+ if (!isObject(payload) || (payload.code !== undefined && payload.code !== 'ok')) {
89
+ throw failure('INVALID_RESPONSE', 'invalid_response', stage);
90
+ }
91
+ return payload;
92
+ } catch (error) {
93
+ if (error && error.isCliError) {
94
+ throw error;
95
+ }
96
+ throw controller.signal.aborted
97
+ ? failure('TIMEOUT', 'request_timeout', stage)
98
+ : failure('NETWORK_ERROR', 'request_failed', stage);
99
+ } finally {
100
+ clearTimeout(timer);
101
+ }
102
+ }
103
+
104
+ function decodeClaims(token) {
105
+ try {
106
+ const value = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
107
+ return isObject(value) ? value : {};
108
+ } catch {
109
+ return {};
110
+ }
111
+ }
112
+
113
+ /** Exchange a cloud Agent credential for an in-memory scoped user credential.
114
+ * This does not issue or persist an OpenYida session.
115
+ */
116
+ async function exchangeQwenWorkAgentToken(options = {}) {
117
+ const env = options.env || process.env;
118
+ const agent = pick(env, ['QWENWORK_AGENT_TOKEN', 'MULE_AGENT_TOKEN', 'AGENT_TOKEN']);
119
+ if (!agent || /[\r\n]/.test(agent.value)) {
120
+ throw failure('CREDENTIAL_MISSING', 'credential_missing', 'resolve_credentials');
121
+ }
122
+ const gateway = pick(env, ['QWENWORK_GATEWAY_URL', 'MULE_GATEWAY_URL']);
123
+ if (!gateway) {
124
+ throw failure('GATEWAY_MISSING', 'gateway_missing', 'resolve_credentials');
125
+ }
126
+ const baseUrl = normalizeBaseUrl(gateway.value);
127
+ const response = await requestJson('POST', `${baseUrl}/api/agent-gw/token/exchange`, agent.value, options, 'exchange');
128
+ // The current cloud endpoint returns {token, scope, ttl} without an envelope.
129
+ const payload = isObject(response.data) ? response.data : response;
130
+ if (typeof payload.token !== 'string' || !payload.token.trim() || /\s/.test(payload.token) ||
131
+ typeof payload.scope !== 'string' || !Number.isFinite(payload.ttl) || payload.ttl <= 0) {
132
+ throw failure('INVALID_RESPONSE', 'invalid_response', 'exchange');
133
+ }
134
+ const decoded = decodeClaims(payload.token);
135
+ if (typeof decoded.exp === 'number' && decoded.exp * 1000 <= Date.now()) {
136
+ throw failure('TOKEN_EXPIRED', 'expired_token', 'exchange');
137
+ }
138
+ return {
139
+ access_token: payload.token,
140
+ base_url: baseUrl,
141
+ credential_source: agent.name,
142
+ scope: payload.scope,
143
+ expires_in: payload.ttl,
144
+ expires_at: typeof decoded.exp === 'number' ? decoded.exp * 1000 : Date.now() + payload.ttl * 1000,
145
+ };
146
+ }
147
+
148
+ /** Query the credential's own origin; never infer a YiDA endpoint or organization. */
149
+ async function getQwenWorkUserInfo(credential, options = {}) {
150
+ const accessToken = credential && credential.access_token;
151
+ if (typeof accessToken !== 'string' || !accessToken || /[\r\n]/.test(accessToken)) {
152
+ throw failure('CREDENTIAL_MISSING', 'credential_missing', 'user_info');
153
+ }
154
+ const baseUrl = normalizeBaseUrl(credential.base_url);
155
+ const response = await requestJson('GET', `${baseUrl}/user/info`, accessToken, options, 'user_info');
156
+ if (response.code !== 'ok' || !isObject(response.data) ||
157
+ typeof response.data.user_id !== 'string' || !response.data.user_id.trim()) {
158
+ throw failure('INVALID_RESPONSE', 'invalid_response', 'user_info');
159
+ }
160
+ const env = options.env || process.env;
161
+ const decoded = decodeClaims(accessToken);
162
+ // Decoding is not local signature verification. The authenticated /user/info
163
+ // response establishes identity; these comparisons catch context drift.
164
+ for (const expected of [env.QWENWORK_USER_ID, env.MULE_USER_ID, decoded.user_id, decoded.sub]) {
165
+ if (expected && String(expected) !== response.data.user_id) {
166
+ throw failure('IDENTITY_MISMATCH', 'identity_mismatch', 'user_info');
167
+ }
168
+ }
169
+ const orgId = response.data.biz && response.data.biz.org_id;
170
+ if (orgId && decoded.biz_org_id && orgId !== decoded.biz_org_id) {
171
+ throw failure('IDENTITY_MISMATCH', 'identity_mismatch', 'user_info');
172
+ }
173
+ return response.data;
174
+ }
175
+
176
+ /** Safe composition for identity consumers: the returned object has no credential. */
177
+ async function getQwenWorkIdentity(options = {}) {
178
+ const credential = await exchangeQwenWorkAgentToken(options);
179
+ const profile = await getQwenWorkUserInfo(credential, options);
180
+ return {
181
+ base_url: credential.base_url,
182
+ credential_source: credential.credential_source,
183
+ scope: credential.scope,
184
+ expires_in: credential.expires_in,
185
+ expires_at: credential.expires_at,
186
+ profile,
187
+ };
188
+ }
189
+
190
+ module.exports = { exchangeQwenWorkAgentToken, getQwenWorkUserInfo, getQwenWorkIdentity };
@@ -0,0 +1,128 @@
1
+ 'use strict';
2
+
3
+ const { sessionIdentity } = require('./session-identity');
4
+
5
+ const { CliError } = require('../core/cli-error');
6
+ const { t } = require('../core/i18n');
7
+ const { DINGTALK_OAUTH_CLIENT_ID } = require('../core/env-manager');
8
+ const { resolveTokenBaseUrl } = require('./token-auth');
9
+
10
+ function failure(code, details = {}) {
11
+ return new CliError(t('qwenwork.login_check_failed'), { code: `QWENWORK_CLOUD_${code}`, details });
12
+ }
13
+
14
+ const CORP_ID = /^(?:(?:ding|yida|ps)[0-9a-f]{32}|dingd8e1123006514592)$/;
15
+
16
+ // Only bounded protocol identifiers are diagnostic data; never forward the response body or message.
17
+ function diagnostics(value, statusCode, credential = {}) {
18
+ const details = { stage: 'yida_exchange', status_code: statusCode };
19
+ if (!value || typeof value !== 'object' || Array.isArray(value)) { return details; }
20
+ const secrets = [credential.access_token, value.access_token, value.refresh_token, value.refreshToken].filter(token => typeof token === 'string' && token);
21
+ const publicValue = text => typeof text === 'string' && !secrets.some(token => text.includes(token));
22
+ if (publicValue(value.status) && value.status.length <= 64 && /^(?:ok|error|failure|[a-z][a-z0-9]*_[a-z0-9_]+)$/.test(value.status)
23
+ && !/^(?:qwex|dws)_/.test(value.status)) { details.status = value.status; }
24
+ if (publicValue(value.stage) && ['request', 'qwen_verify', 'token_issue'].includes(value.stage)) { details.stage = value.stage; }
25
+ if (publicValue(value.exchange_id) && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value.exchange_id)) {
26
+ details.exchange_id = value.exchange_id;
27
+ }
28
+ return details;
29
+ }
30
+
31
+ function origin(value, details = {}) {
32
+ try {
33
+ const url = new URL(value);
34
+ if (typeof value !== 'string' || value.length > 2048 || /\s/.test(value) || url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) { throw new Error(); }
35
+ return url.origin;
36
+ } catch { throw failure('ENDPOINT_INVALID', details); }
37
+ }
38
+
39
+ async function exchangeSession(credential, baseUrl, options) {
40
+ const controller = new AbortController();
41
+ const timeoutMs = options.timeoutMs === undefined ? 15000 : options.timeoutMs;
42
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { throw failure('INVALID_TIMEOUT'); }
43
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
44
+ try {
45
+ const response = await (options.fetch || globalThis.fetch)(`${baseUrl}/openapi/cli/v1/auth/qwenwork/token`, {
46
+ method: 'POST', redirect: 'error', signal: controller.signal,
47
+ headers: { authorization: `Bearer ${credential.access_token}`, accept: 'application/json', 'content-type': 'application/json' },
48
+ // corp_id constrains the resulting YiDA organization or personal space for every identity type.
49
+ body: JSON.stringify({ gateway_url: credential.base_url, ...(options.corpId ? { corp_id: options.corpId } : {}) }),
50
+ });
51
+ const reader = response.body?.getReader();
52
+ if (!reader) { throw failure(response.ok ? 'RESPONSE_INVALID' : 'HTTP_ERROR', diagnostics(null, response.status)); }
53
+ const chunks = []; let bytes = 0;
54
+ try {
55
+ for (let part = await reader.read(); !part.done; part = await reader.read()) {
56
+ bytes += part.value.byteLength;
57
+ if (bytes > 65536) { await reader.cancel(); throw failure(response.ok ? 'RESPONSE_INVALID' : 'HTTP_ERROR', { ...diagnostics(null, response.status), reason: 'response_too_large' }); }
58
+ chunks.push(part.value);
59
+ }
60
+ } finally { reader.releaseLock(); }
61
+ let value;
62
+ try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
63
+ catch { throw failure(response.ok ? 'RESPONSE_INVALID' : 'HTTP_ERROR', { ...diagnostics(null, response.status), reason: 'invalid_json' }); }
64
+ const details = diagnostics(value, response.status, credential);
65
+ if (!response.ok) { throw failure('HTTP_ERROR', details); }
66
+ return { value, details };
67
+ } catch (error) {
68
+ // 上游正文和异常可能回显凭据,仅保留本模块生成的固定错误码。
69
+ if (error instanceof CliError && /^QWENWORK_CLOUD_/.test(error.code)) { throw error; }
70
+ throw failure(controller.signal.aborted ? 'REQUEST_TIMED_OUT' : 'REQUEST_FAILED', { stage: 'yida_exchange' });
71
+ } finally { clearTimeout(timer); }
72
+ }
73
+
74
+ /** 只读取本次沙箱凭据,由宜搭服务端向受信任网关验证身份。 */
75
+ function sandboxCredential(options) {
76
+ const env = options.env || process.env;
77
+ const token = ['QWENWORK_AGENT_TOKEN', 'MULE_AGENT_TOKEN', 'AGENT_TOKEN'].map(name => env[name]).find(value => typeof value === 'string' && value);
78
+ if (!token) { throw new CliError(t('qwenwork.login_check_failed'), { code: 'QWENWORK_CREDENTIAL_MISSING' }); }
79
+ if (token.length > 4096 || /\s/.test(token)) { throw failure('CREDENTIAL_INVALID'); }
80
+ const gateway = env.QWENWORK_GATEWAY_URL || env.MULE_GATEWAY_URL;
81
+ if (!gateway) { throw new CliError(t('qwenwork.login_check_failed'), { code: 'QWENWORK_GATEWAY_MISSING' }); }
82
+ return { access_token: token, base_url: origin(gateway) };
83
+ }
84
+
85
+ /** 只向宜搭提交 Agent Token 和网关地址;返回会话供调用方校验后保存。 */
86
+ async function exchangeQwenWorkCloudSession(options = {}) {
87
+ const baseUrl = origin(options.endpoint || (options.env || process.env).OPENYIDA_ENDPOINT || resolveTokenBaseUrl(options));
88
+ const credential = sandboxCredential(options);
89
+ try {
90
+ const startedAt = Date.now();
91
+ const { value, details } = await exchangeSession(credential, baseUrl, options);
92
+ if (!value || typeof value !== 'object' || Array.isArray(value)) { throw failure('RESPONSE_INVALID', { ...details, reason: 'response_shape' }); }
93
+ if (value.status !== 'ok') { throw failure('EXCHANGE_REJECTED', details); }
94
+ const env = options.env || process.env;
95
+ const hostTokens = ['QWENWORK_AGENT_TOKEN', 'MULE_AGENT_TOKEN', 'AGENT_TOKEN'].map(name => env[name]).filter(Boolean);
96
+ const validToken = token => typeof token === 'string' && !!token && !/\s/.test(token) && token.length <= 32768 && !hostTokens.includes(token);
97
+ // Older cloud deployments may return access-only sessions. Preserve a refresh token when supplied.
98
+ const refreshToken = value.refresh_token === undefined ? value.refreshToken : value.refresh_token;
99
+ const invalid = [
100
+ [value.auth_mode !== 'token', 'auth_mode'],
101
+ [value.can_auto_use !== true, 'can_auto_use'],
102
+ [value.token_type !== 'Bearer', 'token_type'],
103
+ [value.credential_source !== 'qwenwork_cloud', 'credential_source'],
104
+ [!validToken(value.access_token), 'access_token'],
105
+ [!Number.isInteger(value.expires_in) || value.expires_in <= 0 || value.expires_in > 1800, 'expires_in'],
106
+ [typeof value.corp_id !== 'string' || !CORP_ID.test(value.corp_id), 'corp_id'],
107
+ [typeof value.user_id !== 'string' || !value.user_id || value.user_id.trim() !== value.user_id || value.user_id.length > 256, 'user_id'],
108
+ [options.corpId && value.corp_id !== options.corpId, 'corp_id_mismatch'],
109
+ [options.userId && value.user_id !== options.userId, 'user_id_mismatch'],
110
+ ['exchange_ticket' in value, 'exchange_ticket'],
111
+ [refreshToken !== undefined && !validToken(refreshToken), 'refresh_token'],
112
+ [value.client_id !== undefined && value.client_id !== (options.clientId || DINGTALK_OAUTH_CLIENT_ID), 'client_id'],
113
+ ].find(([rejected]) => rejected);
114
+ if (invalid) { throw failure('RESPONSE_INVALID', { ...details, reason: invalid[1] }); }
115
+ return {
116
+ ...sessionIdentity(value),
117
+ auth_mode: 'token', credential_source: 'qwenwork_cloud', token_type: 'Bearer',
118
+ // 可信宜搭签发服务决定业务域名;认证请求仍只发往最初选择的 origin。
119
+ access_token: value.access_token, refresh_token: refreshToken, base_url: value.base_url === undefined ? baseUrl : origin(value.base_url, { ...details, reason: 'base_url' }),
120
+ client_id: options.clientId || DINGTALK_OAUTH_CLIENT_ID,
121
+ corp_id: value.corp_id, user_id: value.user_id,
122
+ user_name: typeof value.user_name === 'string' ? value.user_name : undefined,
123
+ expires_at: startedAt + value.expires_in * 1000,
124
+ };
125
+ } finally { credential.access_token = undefined; }
126
+ }
127
+
128
+ module.exports = { exchangeQwenWorkCloudSession };