openyida 2026.10.23 → 2026.10.26-beta.1
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/README.md +21 -3
- package/bin/yida-pre-beta.js +11 -0
- package/bin/yida.js +18 -5
- package/lib/auth/qwenwork-client.js +190 -0
- package/lib/auth/qwenwork-desktop.js +153 -0
- package/lib/auth/qwenwork-login.js +87 -0
- package/lib/auth/qwenwork.js +37 -0
- package/lib/auth/token-auth.js +8 -1
- package/lib/auth/token-store.js +1 -0
- package/lib/core/agent-capabilities.js +38 -5
- package/lib/core/command-manifest.js +7 -30
- package/lib/core/locales/en.js +19 -15
- package/lib/core/locales/zh.js +18 -14
- package/lib/core/qwenwork-login-guidance.js +32 -0
- package/package.json +13 -5
- package/scripts/install-qwenwork-pre-beta.js +40 -0
- package/yida-skills/SKILL.md +7 -2
- package/yida-skills/references/qwenwork-desktop-login.md +12 -0
- package/yida-skills/references/setup-and-env.md +4 -0
- package/yida-skills/skills/yida-login/SKILL.md +18 -0
- package/yida-skills/skills-index.json +0 -13
- package/lib/app/form-message.js +0 -352
- package/yida-skills/skills/yida-form-message/SKILL.md +0 -53
- package/yida-skills/skills/yida-form-message/references/guide.md +0 -111
package/README.md
CHANGED
|
@@ -143,6 +143,25 @@ openyida login --check-only --json
|
|
|
143
143
|
openyida auth status
|
|
144
144
|
```
|
|
145
145
|
|
|
146
|
+
For QwenWork cloud identity diagnostics, run inside the actual cloud execution environment:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
openyida auth qwenwork user-info --json
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
This command exchanges the injected `QWENWORK_AGENT_TOKEN` (legacy: `MULE_AGENT_TOKEN` / `AGENT_TOKEN`) at `QWENWORK_GATEWAY_URL` (legacy: `MULE_GATEWAY_URL`), then queries `/user/info` on the same origin. It reports identity and scope metadata without printing or persisting either credential. It does not create an OpenYida login session, and QwenWork organization IDs are not YiDA `corpId` values. Existing OAuth/profile behavior is unchanged.
|
|
153
|
+
|
|
154
|
+
Programmatic callers can use `exchangeQwenWorkAgentToken`, `getQwenWorkUserInfo` and `getQwenWorkIdentity` from `lib/auth/qwenwork-client.js`. The exchange function returns an in-memory `access_token` credential for the identity query or a future server-side login exchange; callers must not log or persist it.
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
const { exchangeQwenWorkAgentToken, getQwenWorkUserInfo } = require('openyida/lib/auth/qwenwork-client');
|
|
158
|
+
async function readCloudUser() {
|
|
159
|
+
const credential = await exchangeQwenWorkAgentToken();
|
|
160
|
+
// The result contains QwenWork identifiers, not YiDA IDs.
|
|
161
|
+
return getQwenWorkUserInfo(credential);
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
146
165
|
To reuse an existing shared login profile for the current project, switch first instead of starting a new OAuth login:
|
|
147
166
|
|
|
148
167
|
```bash
|
|
@@ -457,9 +476,9 @@ When preparing a PR that needs a personal Fork, or when you ask how to support t
|
|
|
457
476
|
|
|
458
477
|
| Command | Description |
|
|
459
478
|
|---------|-------------|
|
|
460
|
-
| `openyida login [target-url] [--env <name>\|--intl\|--overseas\|--global\|--yidaapps\|--alibaba] [--client-id <clientId>] [--endpoint <url>] [--no-browser]` | Login with OAuth token mode |
|
|
479
|
+
| `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
480
|
| `openyida logout` | Logout / unbind current project auth |
|
|
462
|
-
| `openyida auth <status\|login\|refresh\|logout\|profiles\|profile switch
|
|
481
|
+
| `openyida auth <status\|login\|refresh\|logout\|profiles\|profile switch\|qwenwork user-info> [--json]` | Token login state and profile management |
|
|
463
482
|
| `openyida org <list\|switch> [--json] [--corp-id <corpId>]` | Organization management (list / switch existing profiles first) |
|
|
464
483
|
| `openyida env [--json\|setup\|list\|show\|switch\|add\|remove] [options]` | Detect AI tool environment & token login state |
|
|
465
484
|
|
|
@@ -516,7 +535,6 @@ When preparing a PR that needs a personal Fork, or when you ask how to support t
|
|
|
516
535
|
| `openyida print-template <list\|inspect\|fields\|build\|create\|copy\|save\|restore\|settings\|status\|order\|render\|files> <appType> <formUuid> [templateId] [--config <file>] [--scene <order\|quotation\|delivery\|receipt\|approval\|codes>] [--bindings <file>] [--name <title>] [--expected-hash <hash>] [--backup <file>] [--out <file>] [--status START\|STOP] [--instance <id>] [--page <n>] [--size <n>] [--allow-permission-change] [--json] \| print-template <list\|render> --detail-url <url> [--template-id <id>] [--json]` | Manage native YiDA print templates, generate document layouts, back up, restore and request PDFs |
|
|
517
536
|
| `openyida form-view <list\|inspect\|save\|create\|order> <appType> <formUuid> [viewUuid] [--config <file>] [--name <title>] [--with-config] [--expected-hash <hash>] [--backup <file>] [--replace] [--json]` | Native form views: list, inspect and configure columns, widths, frozen columns and filters |
|
|
518
537
|
| `openyida custom-button <list\|inspect\|options\|create\|update\|delete> <appType> <formUuid> [buttonUuid] [--config <file>] [--expected-hash <hash>] [--backup <file>] [--yes] [--json]` | Native custom buttons: inspect and configure form edits, automation flows and page links |
|
|
519
|
-
| `openyida form-message <list\|inspect\|options\|create\|update\|delete> <appType> <formUuid> [messageId] [--config <file>] [--expected-hash <hash>] [--backup <file>] [--yes] [--json]` | Native form notifications: inspect triggers, recipients and templates, then safely maintain bindings |
|
|
520
538
|
| `openyida get-schema <appType> <formUuid\|--all> [--summary-json\|--field-map-json\|--analysis-json]` | Get one form Schema or all form Schemas |
|
|
521
539
|
| `openyida check-prd-completeness <prd.md> --app-type <appType> [--build-manifest <file>] [--json]` | Check resources, frontend menu hiding and application theme delivery |
|
|
522
540
|
| `openyida check-design <design.md> [--prd <prd.md>] [--base-dir <dir>] [--json]` | Validate design documents, theme tokens and PRD handoff |
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const config = require('../package.json').openyidaLocalBeta;
|
|
6
|
+
// PRE beta 消费包内测试默认值,真实凭据仍由原认证模块管理。
|
|
7
|
+
process.env.OPENYIDA_ENDPOINT ||= config.baseUrl;
|
|
8
|
+
process.env.OPENYIDA_ENV ||= config.environment;
|
|
9
|
+
process.env.OPENYIDA_AUTH_DIR ||= path.join(os.homedir(), '.openyida', config.authDirectory);
|
|
10
|
+
process.env.OPENYIDA_SKIP_UPDATE_CHECK = '1';
|
|
11
|
+
require('./yida.js');
|
package/bin/yida.js
CHANGED
|
@@ -594,7 +594,21 @@ async function main() {
|
|
|
594
594
|
});
|
|
595
595
|
assertManagedInvocation(command, rawArgs);
|
|
596
596
|
applyQuietFlag();
|
|
597
|
+
// 一次性票据只在当前进程消费,不能先自更新并重启命令。
|
|
598
|
+
switch (command) {
|
|
599
|
+
case 'login-qwenwork':
|
|
600
|
+
await require('../lib/auth/qwenwork-desktop').run(args);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
597
603
|
applyGlobalEnvironmentFlags();
|
|
604
|
+
// 桌面准备完成后继续执行公开命令,由真实登录态决定结果。
|
|
605
|
+
if (command === 'login' && args.includes('--qwenwork')) {
|
|
606
|
+
const loginArgs = applyLoginEnvironmentFlags(args, { inferTargetUrl: true });
|
|
607
|
+
if (hasHelpFlag(loginArgs)) { printLoginHelp(); return; }
|
|
608
|
+
assertNoUnsupportedLegacyLoginFlags(rawArgs, loginArgs);
|
|
609
|
+
await require('../lib/auth/qwenwork-login').run(loginArgs, buildTokenLoginOptions(loginArgs));
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
598
612
|
|
|
599
613
|
const { maybeAutoUpdate } = require('../lib/core/update');
|
|
600
614
|
const updateResult = await maybeAutoUpdate({
|
|
@@ -714,6 +728,10 @@ async function main() {
|
|
|
714
728
|
|
|
715
729
|
case 'auth': {
|
|
716
730
|
const subCommand = args[0];
|
|
731
|
+
if (subCommand === 'qwenwork') {
|
|
732
|
+
await require('../lib/auth/qwenwork').run(args.slice(1));
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
717
735
|
const authArgs = applyLoginEnvironmentFlags(args.slice(1), { inferTargetUrl: true });
|
|
718
736
|
const { getAuthStatus } = require('../lib/core/utils');
|
|
719
737
|
const { tokenLogin, tokenLogout, tokenRefresh } = require('../lib/auth/token-auth');
|
|
@@ -837,11 +855,6 @@ async function main() {
|
|
|
837
855
|
await run(args);
|
|
838
856
|
break;
|
|
839
857
|
}
|
|
840
|
-
case 'form-message': {
|
|
841
|
-
const { run } = require('../lib/app/form-message');
|
|
842
|
-
await run(args);
|
|
843
|
-
break;
|
|
844
|
-
}
|
|
845
858
|
case 'print-template': {
|
|
846
859
|
const { run } = require('../lib/print-template/print-template');
|
|
847
860
|
await run(args);
|
|
@@ -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,153 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { CliError } = require('../core/cli-error');
|
|
4
|
+
const { buildQwenWorkLoginInvocation } = require('../core/qwenwork-login-guidance');
|
|
5
|
+
const { t } = require('../core/i18n');
|
|
6
|
+
const { DINGTALK_OAUTH_CLIENT_ID } = require('../core/env-manager');
|
|
7
|
+
const { saveTokenSession, loadTokenSession, isEnvTokenAuthMode } = require('./token-store');
|
|
8
|
+
const { readManagedContext } = require('../agent/managed-context');
|
|
9
|
+
|
|
10
|
+
const TICKET_ENV = 'OPENYIDA_QWENWORK_EXCHANGE_TICKET';
|
|
11
|
+
const TICKET_CONTEXT_ENV = 'OPENYIDA_QWENWORK_EXCHANGE_TICKET_CONTEXT';
|
|
12
|
+
const CONSUME_PATH = '/openapi/cli/v1/auth/qwenwork/desktop/consume';
|
|
13
|
+
const MAX_RESPONSE_BYTES = 65536;
|
|
14
|
+
|
|
15
|
+
function fail(code) {
|
|
16
|
+
return new CliError(t('qwenwork.desktop_login_failed'), {
|
|
17
|
+
code: `QWENWORK_DESKTOP_${code}`,
|
|
18
|
+
details: { invocation: buildQwenWorkLoginInvocation() },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseTicket(raw, context) {
|
|
23
|
+
try {
|
|
24
|
+
if (typeof raw !== 'string' || !raw || /\s/.test(raw) || raw.length > 32768 || typeof context !== 'string' || context.length > 8192) { throw new Error(); }
|
|
25
|
+
const value = JSON.parse(context);
|
|
26
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
27
|
+
!Number.isSafeInteger(value.expires_at) || value.expires_at <= Date.now() || value.expires_at > Date.now() + 300000 ||
|
|
28
|
+
typeof value.corp_id !== 'string' || !value.corp_id || typeof value.user_id !== 'string' || !value.user_id ||
|
|
29
|
+
typeof value.exchange_id !== 'string' || !value.exchange_id ||
|
|
30
|
+
'access_token' in value || 'refresh_token' in value || 'accessToken' in value || 'refreshToken' in value) { throw new Error(); }
|
|
31
|
+
return { ...value, exchange_ticket: raw };
|
|
32
|
+
} catch {
|
|
33
|
+
throw fail('TICKET_MISSING_OR_INVALID');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function origin(value) {
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(value);
|
|
40
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/') { throw new Error(); }
|
|
41
|
+
return url.origin;
|
|
42
|
+
} catch {
|
|
43
|
+
throw fail('ENDPOINT_INVALID');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function requestJson(url, init, options) {
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs || 15000);
|
|
50
|
+
try {
|
|
51
|
+
const response = await (options.fetch || globalThis.fetch)(url, { ...init, redirect: 'error', signal: controller.signal });
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
if (response.body) { await response.body.cancel(); }
|
|
54
|
+
throw fail('REQUEST_REJECTED');
|
|
55
|
+
}
|
|
56
|
+
const reader = response.body && response.body.getReader();
|
|
57
|
+
if (!reader) { throw fail('RESPONSE_INVALID'); }
|
|
58
|
+
const chunks = [];
|
|
59
|
+
let bytes = 0;
|
|
60
|
+
try {
|
|
61
|
+
for (let next = await reader.read(); !next.done; next = await reader.read()) {
|
|
62
|
+
bytes += next.value.byteLength;
|
|
63
|
+
if (bytes > MAX_RESPONSE_BYTES) { await reader.cancel(); throw fail('RESPONSE_INVALID'); }
|
|
64
|
+
chunks.push(next.value);
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
reader.releaseLock();
|
|
68
|
+
}
|
|
69
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
70
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) { throw fail('RESPONSE_INVALID'); }
|
|
71
|
+
return value;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// 服务端和网络异常可能回显凭据,只透传本模块生成的固定错误。
|
|
74
|
+
if (error instanceof CliError && /^QWENWORK_DESKTOP_/.test(error.code)) { throw error; }
|
|
75
|
+
throw fail(controller.signal.aborted ? 'REQUEST_TIMED_OUT' : 'REQUEST_FAILED');
|
|
76
|
+
} finally {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 客户端内部入口:消费一次性兑换票并持久化双 Token,不调用或重启普通 login。 */
|
|
82
|
+
async function loginQwenWork(options = {}) {
|
|
83
|
+
const env = options.env || process.env;
|
|
84
|
+
const raw = options.exchangeTicket || env[TICKET_ENV];
|
|
85
|
+
const context = options.ticketContext || env[TICKET_CONTEXT_ENV];
|
|
86
|
+
delete env[TICKET_ENV];
|
|
87
|
+
delete env[TICKET_CONTEXT_ENV];
|
|
88
|
+
if (isEnvTokenAuthMode(env) || readManagedContext(env)) { throw fail('MANAGED_AUTH_CONFLICT'); }
|
|
89
|
+
const ticket = parseTicket(raw, context);
|
|
90
|
+
const baseUrl = origin(ticket.base_url);
|
|
91
|
+
const expectedEndpoint = options.endpoint || env.OPENYIDA_ENDPOINT;
|
|
92
|
+
if (expectedEndpoint && origin(expectedEndpoint) !== baseUrl) { throw fail('ENDPOINT_MISMATCH'); }
|
|
93
|
+
if (options.corpId && options.corpId !== ticket.corp_id) { throw fail('IDENTITY_MISMATCH'); }
|
|
94
|
+
const startedAt = Date.now();
|
|
95
|
+
const value = await requestJson(`${baseUrl}${CONSUME_PATH}`, {
|
|
96
|
+
method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', authorization: `Bearer ${ticket.exchange_ticket}` },
|
|
97
|
+
body: '{}',
|
|
98
|
+
}, options);
|
|
99
|
+
const expiresAt = startedAt + Number(value.expires_in) * 1000;
|
|
100
|
+
if (value.status !== 'ok' || value.auth_mode !== 'token' || value.can_auto_use !== true ||
|
|
101
|
+
value.token_type !== 'Bearer' || value.credential_source !== 'qwenwork_desktop' ||
|
|
102
|
+
'refreshToken' in value || 'exchange_ticket' in value ||
|
|
103
|
+
typeof value.access_token !== 'string' || !value.access_token || /\s/.test(value.access_token) || value.access_token.length > 32768 ||
|
|
104
|
+
!Number.isInteger(value.expires_in) || value.expires_in < 1 || value.expires_in > 1800 ||
|
|
105
|
+
typeof value.refresh_token !== 'string' || !value.refresh_token || /\s/.test(value.refresh_token) || value.refresh_token.length > 32768 ||
|
|
106
|
+
!Number.isInteger(value.refresh_token_expires_in) || value.refresh_token_expires_in < 1 ||
|
|
107
|
+
(value.client_id && value.client_id !== DINGTALK_OAUTH_CLIENT_ID) ||
|
|
108
|
+
value.corp_id !== ticket.corp_id || value.user_id !== ticket.user_id || value.exchange_id !== ticket.exchange_id ||
|
|
109
|
+
(value.base_url && origin(value.base_url) !== baseUrl)) { throw fail('CREDENTIAL_INVALID'); }
|
|
110
|
+
const verified = await requestJson(`${baseUrl}/openapi/cli/v1/auth/status`, {
|
|
111
|
+
method: 'GET', headers: { authorization: `Bearer ${value.access_token}`, accept: 'application/json' },
|
|
112
|
+
}, options);
|
|
113
|
+
if (verified.status !== 'ok' || verified.auth_mode !== 'token' || verified.can_auto_use !== true ||
|
|
114
|
+
verified.corp_id !== value.corp_id || verified.user_id !== value.user_id || expiresAt <= Date.now()) { throw fail('IDENTITY_MISMATCH'); }
|
|
115
|
+
const session = saveTokenSession({
|
|
116
|
+
auth_mode: 'token', token_type: 'Bearer', access_token: value.access_token, refresh_token: value.refresh_token,
|
|
117
|
+
expires_at: expiresAt, base_url: baseUrl, client_id: DINGTALK_OAUTH_CLIENT_ID,
|
|
118
|
+
corp_id: value.corp_id, user_id: value.user_id, credential_source: 'qwenwork_desktop',
|
|
119
|
+
}, options);
|
|
120
|
+
const restored = loadTokenSession(options);
|
|
121
|
+
if (!['user', 'project'].includes(session.persistence_scope) || !restored ||
|
|
122
|
+
restored.access_token !== value.access_token || restored.refresh_token !== value.refresh_token || restored.base_url !== baseUrl ||
|
|
123
|
+
restored.corp_id !== value.corp_id || restored.user_id !== value.user_id) { throw fail('PERSISTENCE_FAILED'); }
|
|
124
|
+
// 身份、环境和存储细节仅用于内部校验,不进入模型可见的登录结果。
|
|
125
|
+
return { ok: true, status: 'ok', can_auto_use: true };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function run(args, options = {}) {
|
|
129
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
130
|
+
const invocation = buildQwenWorkLoginInvocation();
|
|
131
|
+
if (args.includes('--json')) { console.log(JSON.stringify({ usage: t('qwenwork.desktop_login_usage'), invocation })); }
|
|
132
|
+
else { console.log(`${t('qwenwork.desktop_login_usage')}\n${t('qwenwork.desktop_login_standalone', invocation.command)}`); }
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const parsed = {};
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
for (let index = 0; index < args.length; index++) {
|
|
138
|
+
const flag = args[index];
|
|
139
|
+
if (seen.has(flag)) { throw new CliError(t('qwenwork.desktop_login_usage'), { code: 'CLI_USAGE_ERROR', exitCode: 2, details: { invocation: buildQwenWorkLoginInvocation() } }); }
|
|
140
|
+
seen.add(flag);
|
|
141
|
+
if (['--json', '--quiet'].includes(flag)) { continue; }
|
|
142
|
+
const key = flag === '--corp-id' ? 'corpId' : flag === '--endpoint' ? 'endpoint'
|
|
143
|
+
: flag === '--exchange-ticket' ? 'exchangeTicket' : flag === '--ticket-context' ? 'ticketContext' : null;
|
|
144
|
+
const value = args[++index];
|
|
145
|
+
if (!key || !value || value.startsWith('--')) { throw new CliError(t('qwenwork.desktop_login_usage'), { code: 'CLI_USAGE_ERROR', exitCode: 2, details: { invocation: buildQwenWorkLoginInvocation() } }); }
|
|
146
|
+
parsed[key] = value;
|
|
147
|
+
}
|
|
148
|
+
const result = await loginQwenWork({ ...options, ...parsed });
|
|
149
|
+
if (args.includes('--json') && !args.includes('--quiet') && (options.env || process.env).YIDA_QUIET !== '1') { console.log(JSON.stringify({ ...result, next_action: 'continue_business_operation_silently', user_visible: false })); }
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { TICKET_ENV, TICKET_CONTEXT_ENV, loginQwenWork, run };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { t } = require('../core/i18n');
|
|
3
|
+
const { CliError } = require('../core/cli-error');
|
|
4
|
+
const { loadTokenSession, resolveTokenSession, isEnvTokenAuthMode } = require('./token-store');
|
|
5
|
+
const { tokenLogin, tokenRefresh, isRefreshAuthRequired } = require('./token-auth');
|
|
6
|
+
|
|
7
|
+
function failure(code) {
|
|
8
|
+
return new CliError(t('qwenwork.login_check_failed'), { code });
|
|
9
|
+
}
|
|
10
|
+
function summary(ok) { return { ok, status: ok ? 'ok' : 'not_logged_in', can_auto_use: ok }; }
|
|
11
|
+
|
|
12
|
+
/** 以服务端状态为准;网络异常不能触发浏览器登录。 */
|
|
13
|
+
async function verify(session, options = {}) {
|
|
14
|
+
if (!session || !session.access_token) { return false; }
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs || 15000);
|
|
17
|
+
try {
|
|
18
|
+
const response = await (options.fetch || globalThis.fetch)(`${session.base_url.replace(/\/$/, '')}/openapi/cli/v1/auth/status`, {
|
|
19
|
+
headers: { authorization: `Bearer ${session.access_token}`, accept: 'application/json' },
|
|
20
|
+
redirect: 'error', signal: controller.signal,
|
|
21
|
+
});
|
|
22
|
+
if ([401, 403].includes(response.status)) { await response.body?.cancel(); return false; }
|
|
23
|
+
if (!response.ok) { await response.body?.cancel(); throw failure('QWENWORK_LOGIN_CHECK_FAILED'); }
|
|
24
|
+
const reader = response.body?.getReader();
|
|
25
|
+
if (!reader) { throw failure('QWENWORK_LOGIN_CHECK_FAILED'); }
|
|
26
|
+
const chunks = []; let size = 0;
|
|
27
|
+
try {
|
|
28
|
+
for (let part = await reader.read(); !part.done; part = await reader.read()) {
|
|
29
|
+
size += part.value.byteLength;
|
|
30
|
+
if (size > 65536) { await reader.cancel(); throw failure('QWENWORK_LOGIN_CHECK_FAILED'); }
|
|
31
|
+
chunks.push(part.value);
|
|
32
|
+
}
|
|
33
|
+
} finally { reader.releaseLock(); }
|
|
34
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
35
|
+
if (value.status !== 'ok' || value.can_auto_use !== true || value.auth_mode !== 'token') {
|
|
36
|
+
if (['not_logged_in', 'invalid_access_token', 'token_expired', 'auth_required'].includes(value.status)) { return false; }
|
|
37
|
+
throw failure('QWENWORK_LOGIN_CHECK_FAILED');
|
|
38
|
+
}
|
|
39
|
+
if (value.corp_id !== session.corp_id || value.user_id !== session.user_id ||
|
|
40
|
+
(options.corpId && value.corp_id !== options.corpId)) { throw failure('QWENWORK_LOGIN_IDENTITY_MISMATCH'); }
|
|
41
|
+
return true;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error instanceof CliError) { throw error; }
|
|
44
|
+
throw failure('QWENWORK_LOGIN_CHECK_FAILED');
|
|
45
|
+
} finally { clearTimeout(timer); }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 公开入口复用、刷新登录态;确需授权时复用普通 login 的前台 OAuth 流程。 */
|
|
49
|
+
async function ensureQwenWorkLogin(options = {}, dependencies = {}) {
|
|
50
|
+
const resolution = resolveTokenSession(options);
|
|
51
|
+
if (resolution.status === 'profile_required') { throw failure('AUTH_PROFILE_REQUIRED'); }
|
|
52
|
+
let session = loadTokenSession(options);
|
|
53
|
+
if (session && options.endpoint && new URL(session.base_url).origin !== new URL(options.endpoint).origin) {
|
|
54
|
+
session = null;
|
|
55
|
+
}
|
|
56
|
+
if (await verify(session, options)) { return summary(true); }
|
|
57
|
+
if (session?.refresh_token) {
|
|
58
|
+
try {
|
|
59
|
+
const refreshed = await (dependencies.refresh || tokenRefresh)(options);
|
|
60
|
+
if (refreshed && refreshed.ok !== false && refreshed.access_token) {
|
|
61
|
+
if (await verify(refreshed, options)) { return summary(true); }
|
|
62
|
+
} else if (!isRefreshAuthRequired(refreshed)) { throw failure('QWENWORK_LOGIN_REFRESH_FAILED'); }
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (!isRefreshAuthRequired(error) && ![401, 403].includes(error.statusCode)) {
|
|
65
|
+
if (error instanceof CliError) { throw error; }
|
|
66
|
+
throw failure('QWENWORK_LOGIN_REFRESH_FAILED');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (options.checkOnly) { return summary(false); }
|
|
71
|
+
if (isEnvTokenAuthMode(options.env || process.env)) { throw failure('QWENWORK_LOGIN_FAILED'); }
|
|
72
|
+
const result = await (dependencies.login || tokenLogin)(options);
|
|
73
|
+
if (!result || result.ok === false || !result.access_token || !await verify(result, options)) {
|
|
74
|
+
throw failure('QWENWORK_LOGIN_FAILED');
|
|
75
|
+
}
|
|
76
|
+
return summary(true);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function run(args, options = {}) {
|
|
80
|
+
const result = await ensureQwenWorkLogin({ ...options, checkOnly: args.includes('--check-only') });
|
|
81
|
+
if (args.includes('--json') && !args.includes('--quiet') && process.env.YIDA_QUIET !== '1') {
|
|
82
|
+
console.log(JSON.stringify(result));
|
|
83
|
+
}
|
|
84
|
+
if (!result.ok) { process.exitCode = 1; }
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
module.exports = { ensureQwenWorkLogin, run };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { CliError } = require('../core/cli-error');
|
|
4
|
+
const { t } = require('../core/i18n');
|
|
5
|
+
const { redactSensitive } = require('../core/redact');
|
|
6
|
+
const { getQwenWorkIdentity } = require('./qwenwork-client');
|
|
7
|
+
|
|
8
|
+
async function run(args, options = {}) {
|
|
9
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
10
|
+
console.log(t('qwenwork.usage'));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (args[0] !== 'user-info' || args.slice(1).some(arg => arg !== '--json')) {
|
|
14
|
+
throw new CliError(t('qwenwork.usage'), { code: 'CLI_USAGE_ERROR', exitCode: 2 });
|
|
15
|
+
}
|
|
16
|
+
const identity = await getQwenWorkIdentity(options);
|
|
17
|
+
const { profile, ...metadata } = identity;
|
|
18
|
+
const result = redactSensitive({
|
|
19
|
+
ok: true,
|
|
20
|
+
provider: 'qwenwork',
|
|
21
|
+
...metadata,
|
|
22
|
+
user: {
|
|
23
|
+
user_id: profile.user_id,
|
|
24
|
+
nickname: profile.nickname,
|
|
25
|
+
username: profile.username,
|
|
26
|
+
email: profile.email,
|
|
27
|
+
is_biz: profile.is_biz,
|
|
28
|
+
biz_org_id: profile.biz && profile.biz.org_id,
|
|
29
|
+
biz_org_name: profile.biz && profile.biz.org_name,
|
|
30
|
+
},
|
|
31
|
+
yida_session_created: false,
|
|
32
|
+
});
|
|
33
|
+
console.log(JSON.stringify(result, null, 2));
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { run };
|
package/lib/auth/token-auth.js
CHANGED
|
@@ -175,6 +175,7 @@ function normalizeTokenResponse(response, baseUrl, clientId) {
|
|
|
175
175
|
);
|
|
176
176
|
return {
|
|
177
177
|
auth_mode: 'token',
|
|
178
|
+
credential_source: data.credential_source,
|
|
178
179
|
status: data.status || 'ok',
|
|
179
180
|
can_auto_use: true,
|
|
180
181
|
message: data.message || data.errorMsg || response.errorMsg,
|
|
@@ -486,7 +487,9 @@ async function tokenRefreshUnlocked(options, session) {
|
|
|
486
487
|
// Token exchange follows the active environment endpoint, just like the
|
|
487
488
|
// legacy login bootstrap. The response may return a different corp base_url
|
|
488
489
|
// for business requests and generated application links.
|
|
489
|
-
|
|
490
|
+
// 桌面会话沿用首次换票的可信 origin;刷新不受后续全局环境切换影响。
|
|
491
|
+
const desktopSession = session.credential_source === 'qwenwork_desktop';
|
|
492
|
+
const authBaseUrlValue = desktopSession ? session.base_url : resolveTokenBaseUrl({
|
|
490
493
|
projectRoot: options.projectRoot,
|
|
491
494
|
endpoint: options.endpoint,
|
|
492
495
|
});
|
|
@@ -499,6 +502,9 @@ async function tokenRefreshUnlocked(options, session) {
|
|
|
499
502
|
requireOkResponse(response);
|
|
500
503
|
const data = getTokenPayload(response);
|
|
501
504
|
const responseBusinessBaseUrl = data.base_url || data.baseUrl;
|
|
505
|
+
if (desktopSession && responseBusinessBaseUrl && normalizeBaseUrl(responseBusinessBaseUrl, null) !== session.base_url) {
|
|
506
|
+
throw createTokenAuthRequiredError('refresh_origin_mismatch');
|
|
507
|
+
}
|
|
502
508
|
const businessBaseUrl = session.base_url || authBaseUrlValue;
|
|
503
509
|
const normalized = normalizeTokenResponse({
|
|
504
510
|
...data,
|
|
@@ -507,6 +513,7 @@ async function tokenRefreshUnlocked(options, session) {
|
|
|
507
513
|
user_id: data.user_id || data.userId || session.user_id,
|
|
508
514
|
user_name: data.user_name || data.userName || session.user_name,
|
|
509
515
|
refresh_token: data.refresh_token || data.refreshToken || session.refresh_token,
|
|
516
|
+
credential_source: data.credential_source || session.credential_source,
|
|
510
517
|
}, businessBaseUrl, clientId);
|
|
511
518
|
if (!normalized.access_token) {
|
|
512
519
|
return {
|
package/lib/auth/token-store.js
CHANGED
|
@@ -276,6 +276,7 @@ function normalizeTokenSession(session = {}) {
|
|
|
276
276
|
);
|
|
277
277
|
const normalized = {
|
|
278
278
|
auth_mode: 'token',
|
|
279
|
+
credential_source: session.credential_source,
|
|
279
280
|
token_type: session.token_type || session.tokenType || 'Bearer',
|
|
280
281
|
access_token: session.access_token || session.accessToken,
|
|
281
282
|
refresh_token: session.refresh_token || session.refreshToken,
|