openyida 2026.10.13 → 2026.10.14
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 +76 -23
- package/bin/yida.js +32 -2
- package/lib/agent/PROTOCOL.md +201 -0
- package/lib/agent/ask-human-help.js +19 -0
- package/lib/agent/ask-human.js +222 -0
- package/lib/agent/cmd.js +405 -0
- package/lib/agent/command-receipt.js +84 -0
- package/lib/agent/connection-store.js +148 -0
- package/lib/agent/diagnostic-log.js +133 -0
- package/lib/agent/frozen-bundle.js +298 -0
- package/lib/agent/managed-command-map.js +47 -0
- package/lib/agent/managed-context.js +40 -0
- package/lib/agent/managed-run.js +84 -0
- package/lib/agent/mutation-receipt.js +82 -0
- package/lib/agent/private-directory.js +63 -0
- package/lib/agent/provider-preflight.js +257 -0
- package/lib/agent/providers.js +4 -0
- package/lib/agent/runtime-catalog.json +42 -0
- package/lib/agent/runtime-manifest.js +96 -0
- package/lib/agent/runtime-package.js +155 -0
- package/lib/agent/stdio.js +321 -0
- package/lib/app/check-prd-completeness.js +15 -4
- package/lib/app/create-form/batch.js +30 -9
- package/lib/app/create-form.js +7 -2
- package/lib/app/custom-theme.js +13 -3
- package/lib/app/get-schema.js +6 -1
- package/lib/app/update-app.js +13 -1
- package/lib/app/update-form-config.js +71 -80
- package/lib/asset/asset-plan.js +6 -4
- package/lib/auth/profile-lock.js +89 -0
- package/lib/auth/token-auth.js +57 -6
- package/lib/auth/token-store.js +26 -2
- package/lib/core/command-manifest.js +202 -118
- package/lib/core/locales/en.js +48 -4
- package/lib/core/locales/zh.js +48 -4
- package/lib/core/managed-policy.js +88 -0
- package/lib/core/query-data.js +22 -3
- package/lib/core/sample.js +1 -0
- package/lib/core/update.js +3 -1
- package/lib/core/utils.js +34 -1
- package/lib/design-plan/materialize.js +4 -21
- package/lib/design-plan/render-html.js +299 -0
- package/lib/page-config/save-share-config.js +16 -5
- package/lib/samples/yida-data-management/empty-query.js +69 -0
- package/package.json +10 -1
- package/scripts/postinstall.js +2 -2
- package/yida-skills/SKILL.md +32 -2
- package/yida-skills/references/local-agent-diagnostics.md +154 -0
- package/yida-skills/references/local-agent-preflight.md +56 -0
- package/yida-skills/references/query-condition-guide.md +223 -195
- package/yida-skills/skills/yida-app/workflow/plan/step-4-deliver.md +11 -3
- package/yida-skills/skills/yida-canvas-data-binding/SKILL.md +8 -5
- package/yida-skills/skills/yida-data-management/SKILL.md +64 -19
- package/yida-skills/skills/yida-data-management/references/data-format-guide.md +61 -25
- package/yida-skills/skills/yida-data-source-connectors/SKILL.md +6 -0
- package/yida-skills/skills/yida-design/SKILL.md +2 -0
- package/yida-skills/skills/yida-design/references/ask-human-interaction-contract.md +2 -0
- package/yida-skills/skills/yida-design/templates/design-themes/README.md +1 -1
- package/yida-skills/skills/yida-document-markdown/SKILL.md +6 -0
- package/yida-skills/skills/yida-image-assets/SKILL.md +1 -1
- package/yida-skills/skills/yida-tingji/SKILL.md +4 -0
- package/yida-skills/skills-index.json +52 -7
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { resolveManagedPolicy } = require('../core/managed-policy');
|
|
4
|
+
|
|
5
|
+
// Compatibility adapter for receipts and application checks. Scope and mutation
|
|
6
|
+
// metadata come from the same manifest consumed by the invocation guard.
|
|
7
|
+
function resolveManagedAppCommand(command, args = []) {
|
|
8
|
+
if (args.includes('--help') || args.includes('-h')) {return null;}
|
|
9
|
+
const selected = resolveManagedPolicy(command, args);
|
|
10
|
+
if (selected?.policy.scope !== 'app') {return null;}
|
|
11
|
+
const policy = selected.policy;
|
|
12
|
+
let target;
|
|
13
|
+
if (policy.appParser === 'prd-completeness') {
|
|
14
|
+
const { parseArgs } = require('../app/check-prd-completeness');
|
|
15
|
+
try {
|
|
16
|
+
const parsed = parseArgs(args, { strict: true });
|
|
17
|
+
if (!parsed.prdPath || !parsed.appType) {return null;}
|
|
18
|
+
target = { appType: parsed.appType };
|
|
19
|
+
} catch {return null;}
|
|
20
|
+
} else if (policy.appOption) {
|
|
21
|
+
const positions = args.flatMap((arg, index) => arg === policy.appOption ? [index] : []);
|
|
22
|
+
if (positions.length !== 1 || !args[positions[0] + 1] || args[positions[0] + 1].startsWith('-')) {return null;}
|
|
23
|
+
target = { appType: args[positions[0] + 1] };
|
|
24
|
+
} else {
|
|
25
|
+
if (policy.sourcePosition !== undefined && (!args[policy.sourcePosition] || /^APP_/i.test(args[policy.sourcePosition]) || args[policy.sourcePosition].startsWith('-'))) {return null;}
|
|
26
|
+
target = { appPosition: policy.appPosition };
|
|
27
|
+
}
|
|
28
|
+
const mutation = policy.mutation;
|
|
29
|
+
return { ...target, ...(mutation ? { operation: mutation.operation,
|
|
30
|
+
...(mutation.formUuidPosition !== undefined ? { formUuid: values => values[mutation.formUuidPosition] }
|
|
31
|
+
: mutation.formUuidOption ? { formUuid: values => {
|
|
32
|
+
const positions = values.flatMap((value, index) => value === mutation.formUuidOption ? [index] : []);
|
|
33
|
+
return positions.length === 1 && values[positions[0] + 1] && !values[positions[0] + 1].startsWith('-')
|
|
34
|
+
? values[positions[0] + 1] : undefined;
|
|
35
|
+
} } : {}) } : {}) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function classifyManagedMutation(command, args = []) {
|
|
39
|
+
const entry = resolveManagedAppCommand(command, args);
|
|
40
|
+
const policy = resolveManagedPolicy(command, args)?.policy;
|
|
41
|
+
if (policy?.mutation?.requiredFlags?.some(flag => !args.includes(flag)) || policy?.mutation?.forbiddenFlags?.some(flag => args.includes(flag))) {return null;}
|
|
42
|
+
return entry?.operation
|
|
43
|
+
? { operation: entry.operation, ...(entry.formUuid ? { formUuid: entry.formUuid(args) } : {}) }
|
|
44
|
+
: null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { resolveManagedAppCommand, classifyManagedMutation };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { CliError } = require('../core/cli-error');
|
|
4
|
+
const { t } = require('../core/i18n');
|
|
5
|
+
|
|
6
|
+
function managedError(code, key, details = {}) {
|
|
7
|
+
return new CliError(t(`agent.${key}`), { code, details: { ...details, retryable: false } });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Supplied only by the Go-owned attempt launcher. The fixed-value task grant has
|
|
11
|
+
// a server-managed lease and is app-scoped by Tianshu; ordinary OpenYida profiles
|
|
12
|
+
// are never consulted and Node cannot renew the task grant.
|
|
13
|
+
function readManagedContext(env = process.env) {
|
|
14
|
+
if (env.OPENYIDA_MANAGED_RUN !== '1') {return null;}
|
|
15
|
+
const context = {
|
|
16
|
+
appType: env.OPENYIDA_AGENT_APP_TYPE,
|
|
17
|
+
corpId: env.OPENYIDA_AGENT_CORP_ID,
|
|
18
|
+
userId: env.OPENYIDA_AGENT_USER_ID,
|
|
19
|
+
runId: env.OPENYIDA_AGENT_RUN_ID,
|
|
20
|
+
attemptId: env.OPENYIDA_AGENT_ATTEMPT_ID,
|
|
21
|
+
formUuid: env.OPENYIDA_AGENT_FORM_UUID || null,
|
|
22
|
+
baseUrl: env.OPENYIDA_AGENT_BASE_URL,
|
|
23
|
+
taskGrant: env.OPENYIDA_AGENT_TASK_GRANT,
|
|
24
|
+
};
|
|
25
|
+
let parsed;
|
|
26
|
+
try {parsed = new URL(context.baseUrl);} catch {parsed = null;}
|
|
27
|
+
if (!/^APP_[a-zA-Z0-9_-]{1,200}$/.test(context.appType || '') ||
|
|
28
|
+
['corpId', 'userId', 'runId', 'attemptId'].some(key => typeof context[key] !== 'string' || !context[key] || context[key].length > 512 || /\s/.test(context[key]) || [...context[key]].some(char => char.charCodeAt(0) < 32)) ||
|
|
29
|
+
!parsed || parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash ||
|
|
30
|
+
typeof context.taskGrant !== 'string' || context.taskGrant.length < 16 || context.taskGrant.length > 4096 || /\s/.test(context.taskGrant)) {
|
|
31
|
+
throw managedError('MANAGED_CONTEXT_INVALID', 'managed_context_invalid');
|
|
32
|
+
}
|
|
33
|
+
if (context.formUuid !== null && (context.formUuid.length > 256 || !/^(?:FORM|REPORT)-[a-zA-Z0-9_-]+$/.test(context.formUuid))) {
|
|
34
|
+
throw managedError('MANAGED_CONTEXT_INVALID', 'managed_context_invalid');
|
|
35
|
+
}
|
|
36
|
+
context.baseUrl = context.baseUrl.replace(/\/+$/, '');
|
|
37
|
+
return context;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { readManagedContext, managedError };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { readManagedContext, managedError } = require('./managed-context');
|
|
4
|
+
const { resolveManagedAppCommand } = require('./managed-command-map');
|
|
5
|
+
const { resolveManagedPolicy } = require('../core/managed-policy');
|
|
6
|
+
|
|
7
|
+
const IDENTITY_OPTIONS = new Set([
|
|
8
|
+
'--profile', '--auth-profile', '--corp-id', '--user-id', '--client-id', '--endpoint', '--base-url',
|
|
9
|
+
'--env', '--public', '--intl', '--international', '--overseas', '--global', '--yidaapps', '--alibaba',
|
|
10
|
+
'--access-token', '--refresh-token', '--token', '--auth-dir', '--app-type', '--appType', '--app_type',
|
|
11
|
+
'--system-token-app', '--connector-system-token-app',
|
|
12
|
+
]);
|
|
13
|
+
const HELP_OPTIONS = new Set(['--help', '-h', '--json', '--quiet']);
|
|
14
|
+
|
|
15
|
+
function resolveManagedHelp(command, args = [], options = {}) {
|
|
16
|
+
if (command === 'agent' && args.length === 2 && args[0] === 'ask-human' && ['--help', '-h'].includes(args[1])) {
|
|
17
|
+
return [require('./ask-human-help')];
|
|
18
|
+
}
|
|
19
|
+
const topLevel = command === '--help' || command === '-h';
|
|
20
|
+
const helpFlags = [command, ...args].filter(arg => arg === '--help' || arg === '-h');
|
|
21
|
+
if (helpFlags.length !== 1 || typeof command !== 'string') {return null;}
|
|
22
|
+
const flags = args.filter(arg => HELP_OPTIONS.has(arg));
|
|
23
|
+
if (new Set(flags).size !== flags.length || args.some(arg => arg.startsWith('-') && !HELP_OPTIONS.has(arg))) {return null;}
|
|
24
|
+
const path = topLevel ? [] : [command, ...args.filter(arg => !HELP_OPTIONS.has(arg))];
|
|
25
|
+
if (topLevel && args.some(arg => !HELP_OPTIONS.has(arg))) {return null;}
|
|
26
|
+
const manifest = require('../core/command-manifest').buildCommandManifest(options);
|
|
27
|
+
const entries = manifest.commands.filter(entry => path.length <= entry.path.length &&
|
|
28
|
+
path.every((token, index) => /^[a-z][a-z0-9-]*$/.test(token) && token === entry.path[index]));
|
|
29
|
+
return entries.length ? entries : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function assertManagedInvocation(command, args = [], options = {}) {
|
|
33
|
+
const context = readManagedContext(options.env || process.env);
|
|
34
|
+
if (!context) {return null;}
|
|
35
|
+
// Before global flags, auto-update, and command modules with side effects.
|
|
36
|
+
const declared = resolveManagedPolicy(command, args);
|
|
37
|
+
const scopeOptions = declared?.policy.appOption ? [declared.policy.appOption] : command === 'check-prd-completeness'
|
|
38
|
+
? require('../app/check-prd-completeness').APP_TYPE_OPTIONS : [];
|
|
39
|
+
if (args.some(value => {
|
|
40
|
+
const option = String(value).split('=')[0];
|
|
41
|
+
return IDENTITY_OPTIONS.has(option) && !scopeOptions.includes(option);
|
|
42
|
+
})) {
|
|
43
|
+
throw managedError('MANAGED_OVERRIDE_FORBIDDEN', 'managed_override_forbidden');
|
|
44
|
+
}
|
|
45
|
+
if ([command, ...args].some(arg => arg === '--help' || arg === '-h')) {
|
|
46
|
+
if (resolveManagedHelp(command, args)) {return context;}
|
|
47
|
+
throw managedError('MANAGED_COMMAND_UNSUPPORTED', 'managed_command_unsupported');
|
|
48
|
+
}
|
|
49
|
+
if (['--version', '-v'].includes(command)) {return context;}
|
|
50
|
+
const selected = resolveManagedPolicy(command, args);
|
|
51
|
+
const unsupported = reason => {
|
|
52
|
+
throw managedError('MANAGED_COMMAND_UNSUPPORTED', 'managed_command_unsupported', {
|
|
53
|
+
...(selected ? { commandId: selected.entry.id } : {}), reason,
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
if (!selected) {unsupported('command_or_action_unknown');}
|
|
57
|
+
const { policy, policyArgs } = selected;
|
|
58
|
+
if (policy.scope === 'denied') {unsupported(policy.reason);}
|
|
59
|
+
if (policy.scope === 'local') {
|
|
60
|
+
if (policy.allowedFlags && (policyArgs.some(arg => !policy.allowedFlags.includes(arg)) ||
|
|
61
|
+
new Set(policyArgs).size !== policyArgs.length || policy.requiredFlags?.some(flag => !policyArgs.includes(flag)))) {
|
|
62
|
+
unsupported('arguments_outside_declared_scope');
|
|
63
|
+
}
|
|
64
|
+
return context;
|
|
65
|
+
}
|
|
66
|
+
if (policy.scope === 'selected_input') {
|
|
67
|
+
let inputs;
|
|
68
|
+
try {inputs = JSON.parse((options.env || process.env).OPENYIDA_AGENT_INPUT_REFS || '[]');} catch {inputs = null;}
|
|
69
|
+
const selectedInput = Array.isArray(inputs) && inputs.some(input => input.type === policy.inputType && input[policy.inputField] === policyArgs[0]);
|
|
70
|
+
if (!selectedInput || policyArgs.length > 2 || (policyArgs[1] !== undefined && policyArgs[1] !== '--json')) {
|
|
71
|
+
unsupported('input_not_selected_or_arguments_invalid');
|
|
72
|
+
}
|
|
73
|
+
return context;
|
|
74
|
+
}
|
|
75
|
+
const entry = resolveManagedAppCommand(command, args);
|
|
76
|
+
if (!entry) {unsupported('application_target_unresolved');}
|
|
77
|
+
const appType = entry.appType === undefined ? args[entry.appPosition] : entry.appType;
|
|
78
|
+
if (appType !== context.appType || (entry.appPosition !== undefined && args.slice(0, entry.appPosition).some(arg => arg.startsWith('-')))) {
|
|
79
|
+
throw managedError('MANAGED_APP_MISMATCH', 'managed_app_mismatch');
|
|
80
|
+
}
|
|
81
|
+
return context;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { assertManagedInvocation, resolveManagedHelp };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
4
|
+
const APP_TYPE = /^APP_[A-Za-z0-9_-]{1,200}$/;
|
|
5
|
+
const { classifyManagedMutation } = require('./managed-command-map');
|
|
6
|
+
|
|
7
|
+
function safeText(value, max = 256) {
|
|
8
|
+
return typeof value === 'string' && value.trim() && value.length <= max ? value.trim() : undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function resourceId(...values) {
|
|
12
|
+
return values.map((value) => safeText(value, 128)).find((value) => value && ID.test(value));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function safeOutput(value) {
|
|
16
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {return null;}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Captures only successful JSON result metadata for a Runtime-managed command.
|
|
22
|
+
* Console output is preserved byte-for-byte; ordinary OpenYida invocations are untouched.
|
|
23
|
+
*/
|
|
24
|
+
function beginManagedMutationCapture({ command, args, env = process.env, consoleObject = console, eventId } = {}) {
|
|
25
|
+
const classification = classifyManagedMutation(command, args);
|
|
26
|
+
const appType = env.OPENYIDA_AGENT_APP_TYPE;
|
|
27
|
+
const active = env.OPENYIDA_MANAGED_RUN === '1' && classification && ID.test(eventId || '') && APP_TYPE.test(appType || '');
|
|
28
|
+
if (!active) {return { commit: () => null, discard: () => undefined };}
|
|
29
|
+
|
|
30
|
+
const original = consoleObject.log;
|
|
31
|
+
let latest = null;
|
|
32
|
+
let explicitFailure = false;
|
|
33
|
+
let restored = false;
|
|
34
|
+
consoleObject.log = function managedMutationConsoleLog(...values) {
|
|
35
|
+
try {
|
|
36
|
+
const text = values.length === 1 && typeof values[0] === 'string' ? values[0].trim() : '';
|
|
37
|
+
if (text.startsWith('{') && text.endsWith('}') && text.length <= 64 * 1024) {
|
|
38
|
+
const parsed = safeOutput(JSON.parse(text));
|
|
39
|
+
if (parsed?.success === false) {explicitFailure = true;}
|
|
40
|
+
if (parsed?.success === true) {latest = parsed;}
|
|
41
|
+
}
|
|
42
|
+
} catch { /* CLI output is not a protocol unless it is valid allowlisted JSON. */ }
|
|
43
|
+
return original.apply(this, values);
|
|
44
|
+
};
|
|
45
|
+
const restore = () => {
|
|
46
|
+
if (!restored) {consoleObject.log = original;}
|
|
47
|
+
restored = true;
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
commit() {
|
|
51
|
+
restore();
|
|
52
|
+
if (explicitFailure || !latest) {return null;}
|
|
53
|
+
const isReport = command === 'create-report' || command === 'append-chart';
|
|
54
|
+
const formUuid = resourceId(latest?.formUuid, latest?.pageId,
|
|
55
|
+
isReport ? latest?.reportId : undefined, classification.formUuid);
|
|
56
|
+
const pageType = safeText(latest?.pageType, 64) || (isReport ? 'report' : undefined);
|
|
57
|
+
const updatedFields = safeOutput(latest?.updatedFields);
|
|
58
|
+
const appName = safeText(latest?.appName || updatedFields?.name);
|
|
59
|
+
const appIcon = safeText(latest?.appIcon || updatedFields?.icon);
|
|
60
|
+
const colour = safeText(latest?.colour || updatedFields?.colour, 64);
|
|
61
|
+
const receipt = {
|
|
62
|
+
schemaVersion: 'openyida.mutation.v1',
|
|
63
|
+
eventId,
|
|
64
|
+
source: 'openyida-cli',
|
|
65
|
+
operation: classification.operation,
|
|
66
|
+
status: 'succeeded',
|
|
67
|
+
appType,
|
|
68
|
+
...(formUuid ? { formUuid } : {}),
|
|
69
|
+
...(pageType ? { pageType } : {}),
|
|
70
|
+
...(appName ? { appName } : {}),
|
|
71
|
+
...(appIcon ? { appIcon } : {}),
|
|
72
|
+
...(colour ? { colour } : {}),
|
|
73
|
+
...(safeText(latest?.formName || latest?.formTitle || latest?.pageName || (isReport && latest?.reportTitle)) ? { formName: safeText(latest?.formName || latest?.formTitle || latest?.pageName || (isReport && latest?.reportTitle)) } : {}),
|
|
74
|
+
...(safeText(latest?.schemaHash, 128) ? { schemaHash: safeText(latest.schemaHash, 128) } : {}),
|
|
75
|
+
};
|
|
76
|
+
return receipt;
|
|
77
|
+
},
|
|
78
|
+
discard: restore,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { beginManagedMutationCapture, classifyManagedMutation };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
const { CliError } = require('../core/cli-error');
|
|
7
|
+
const { t } = require('../core/i18n');
|
|
8
|
+
|
|
9
|
+
function directoryError() {
|
|
10
|
+
return new CliError(t('agent.private_directory_failed'), { code: 'AGENT_PRIVATE_DIRECTORY_INVALID' });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Node's mkdir mode is ignored on Windows. Create each missing directory with
|
|
14
|
+
// an inheritable private DACL before any credentials or logs are written.
|
|
15
|
+
// Existing directories are never re-permissioned here.
|
|
16
|
+
function mkdirPrivate(directory) {
|
|
17
|
+
if (process.platform !== 'win32') {
|
|
18
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const target = path.resolve(directory);
|
|
22
|
+
const missing = [];
|
|
23
|
+
let ancestor = target;
|
|
24
|
+
while (!fs.existsSync(ancestor)) {
|
|
25
|
+
missing.unshift(ancestor);
|
|
26
|
+
ancestor = path.dirname(ancestor);
|
|
27
|
+
}
|
|
28
|
+
for (let parent = ancestor; ; parent = path.dirname(parent)) {
|
|
29
|
+
const stat = fs.lstatSync(parent);
|
|
30
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {throw directoryError();}
|
|
31
|
+
if (parent === path.dirname(parent)) {break;}
|
|
32
|
+
}
|
|
33
|
+
if (!missing.length) {return;}
|
|
34
|
+
const script = `
|
|
35
|
+
$ErrorActionPreference = 'Stop'
|
|
36
|
+
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
37
|
+
$acl = New-Object System.Security.AccessControl.DirectorySecurity
|
|
38
|
+
$acl.SetOwner($identity)
|
|
39
|
+
$acl.SetAccessRuleProtection($true, $false)
|
|
40
|
+
foreach ($sid in @($identity.Value, 'S-1-5-18', 'S-1-5-32-544')) {
|
|
41
|
+
$principal = New-Object System.Security.Principal.SecurityIdentifier($sid)
|
|
42
|
+
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($principal, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
|
|
43
|
+
$acl.AddAccessRule($rule)
|
|
44
|
+
}
|
|
45
|
+
foreach ($directory in ($env:OPENYIDA_PRIVATE_DIRECTORIES | ConvertFrom-Json)) {
|
|
46
|
+
if ([System.IO.Directory]::Exists($directory)) {throw 'Directory changed during creation'}
|
|
47
|
+
[System.IO.Directory]::CreateDirectory($directory, $acl) | Out-Null
|
|
48
|
+
}
|
|
49
|
+
`;
|
|
50
|
+
try {
|
|
51
|
+
execFileSync(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
|
52
|
+
['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
53
|
+
windowsHide: true, timeout: 15000, stdio: 'pipe',
|
|
54
|
+
env: { ...process.env, OPENYIDA_PRIVATE_DIRECTORIES: JSON.stringify(missing) },
|
|
55
|
+
});
|
|
56
|
+
} catch {
|
|
57
|
+
throw directoryError();
|
|
58
|
+
}
|
|
59
|
+
const stat = fs.lstatSync(target);
|
|
60
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {throw directoryError();}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { mkdirPrivate };
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawn, execFile } = require('child_process');
|
|
7
|
+
const { runtimeEnvironment } = require('./stdio');
|
|
8
|
+
const { t } = require('../core/i18n');
|
|
9
|
+
|
|
10
|
+
const PROVIDERS = require('./providers');
|
|
11
|
+
const DOCS = {
|
|
12
|
+
qoder: 'https://docs.qoder.com/cli/installation',
|
|
13
|
+
codex: 'https://developers.openai.com/codex/cli/',
|
|
14
|
+
opencode: 'https://opencode.ai/docs/',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function exists(file) {
|
|
18
|
+
try { return fs.statSync(file).isFile(); } catch { return false; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Finite installation locations only; never recursively search a user's disk.
|
|
22
|
+
function installationPaths(env, platform, home) {
|
|
23
|
+
const join = path.join;
|
|
24
|
+
const bin = home ? ['.local/bin', '.qoder/bin', '.codex/bin', '.opencode/bin'].map(p => join(home, p)) : [];
|
|
25
|
+
const desktop = { qoder: [], codex: [], opencode: [] };
|
|
26
|
+
const bundled = { qoder: [], codex: [], opencode: [] };
|
|
27
|
+
if (platform === 'win32') {
|
|
28
|
+
if (env.APPDATA) { bin.push(join(env.APPDATA, 'npm')); }
|
|
29
|
+
if (env.LOCALAPPDATA) {
|
|
30
|
+
desktop.qoder.push(join(env.LOCALAPPDATA, 'Programs/Qoder/Qoder.exe'));
|
|
31
|
+
desktop.codex.push(join(env.LOCALAPPDATA, 'OpenAI/Codex/Codex.exe'));
|
|
32
|
+
const root = join(env.LOCALAPPDATA, 'OpenAI/Codex/bin');
|
|
33
|
+
try {
|
|
34
|
+
const versions = fs.readdirSync(root, { withFileTypes: true }).filter(d => d.isDirectory())
|
|
35
|
+
.map(d => join(root, d.name, 'codex.exe')).filter(exists)
|
|
36
|
+
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs).slice(0, 8);
|
|
37
|
+
bundled.codex.push(...versions);
|
|
38
|
+
} catch { /* Desktop CLI is optional. */ }
|
|
39
|
+
}
|
|
40
|
+
if (env.ProgramFiles) { desktop.qoder.push(join(env.ProgramFiles, 'Qoder/Qoder.exe')); }
|
|
41
|
+
} else if (platform === 'darwin') {
|
|
42
|
+
bin.push('/opt/homebrew/bin', '/usr/local/bin');
|
|
43
|
+
for (const root of ['/Applications', ...(home ? [join(home, 'Applications')] : [])]) {
|
|
44
|
+
desktop.qoder.push(join(root, 'Qoder.app/Contents/MacOS/Qoder'));
|
|
45
|
+
desktop.qoder.push(join(root, 'QoderWork.app/Contents/MacOS/QoderWork'));
|
|
46
|
+
desktop.codex.push(join(root, 'Codex.app/Contents/MacOS/Codex'));
|
|
47
|
+
desktop.codex.push(join(root, 'ChatGPT.app/Contents/MacOS/ChatGPT'));
|
|
48
|
+
bundled.qoder.push(join(root, 'Qoder.app/Contents/Resources/bin/qodercli'));
|
|
49
|
+
bundled.qoder.push(join(root, 'QoderWork.app/Contents/Resources/bin/qodercli'));
|
|
50
|
+
for (const app of ['Codex.app', 'ChatGPT.app']) {
|
|
51
|
+
const resources = join(root, app, 'Contents/Resources');
|
|
52
|
+
// Prefer the bundled native CLI; the bin entry is an optional shell launcher.
|
|
53
|
+
bundled.codex.push(join(resources, 'codex-cli/CodexCLI.app/Contents/MacOS/codex'));
|
|
54
|
+
bundled.codex.push(join(resources, 'codex'));
|
|
55
|
+
bundled.codex.push(join(resources, 'codex-cli/bin/codex'));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
bin.push('/usr/local/bin', '/usr/bin');
|
|
60
|
+
desktop.qoder.push('/opt/Qoder/qoder', '/opt/qoder/qoder');
|
|
61
|
+
if (home) { desktop.qoder.push(join(home, '.local/share/applications/qoder.desktop')); }
|
|
62
|
+
}
|
|
63
|
+
return { bin, desktop, bundled };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function inventory(env = process.env, platform = process.platform, home = env.HOME || env.USERPROFILE || (env === process.env ? os.homedir() : '')) {
|
|
67
|
+
const locations = installationPaths(env, platform, home);
|
|
68
|
+
const paths = (env.PATH || env.Path || env.path || '').split(platform === 'win32' ? ';' : ':')
|
|
69
|
+
.map(p => p.replace(/^"(.*)"$/, '$1')).filter(p => p && path.isAbsolute(p));
|
|
70
|
+
const extensions = platform === 'win32' ? (env.PATHEXT || '.EXE;.CMD;.BAT').split(';').map(e => e.toLowerCase()) : [''];
|
|
71
|
+
return PROVIDERS.map(provider => {
|
|
72
|
+
const candidates = [];
|
|
73
|
+
const desktopExecutables = locations.desktop[provider].map(candidate => {
|
|
74
|
+
try { return fs.realpathSync(candidate); } catch { return path.resolve(candidate); }
|
|
75
|
+
});
|
|
76
|
+
for (const name of provider === 'qoder' ? ['qodercli', 'qoder'] : [provider]) {
|
|
77
|
+
for (const dir of [...paths, ...locations.bin]) {
|
|
78
|
+
for (const ext of extensions) { candidates.push(path.join(dir, name + ext)); }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
candidates.push(...locations.bundled[provider]);
|
|
82
|
+
const found = [];
|
|
83
|
+
for (const candidate of candidates) {
|
|
84
|
+
try {
|
|
85
|
+
const resolved = fs.realpathSync(candidate);
|
|
86
|
+
// A GUI binary is not a CLI, even when its directory appears on PATH.
|
|
87
|
+
if (desktopExecutables.some(p => p.toLowerCase() === resolved.toLowerCase())) { continue; }
|
|
88
|
+
fs.accessSync(resolved, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
|
|
89
|
+
if (fs.statSync(resolved).isFile() && !found.includes(resolved)) { found.push(resolved); }
|
|
90
|
+
} catch { /* Missing candidates are normal. */ }
|
|
91
|
+
}
|
|
92
|
+
return { provider, desktopDetected: locations.desktop[provider].some(exists) || locations.bundled[provider].some(exists), candidates: found };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function discoverProviders(env = process.env, platform = process.platform, home) {
|
|
97
|
+
return inventory(env, platform, home).filter(p => p.candidates.length).map(p => ({
|
|
98
|
+
profileId: `openyida.${p.provider}`, provider: p.provider, executable: p.candidates[0],
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function powerShellLiteral(value) {
|
|
103
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Match the Go Runtime's npm launcher handling without cmd.exe interpolation.
|
|
107
|
+
function probeCommand(executable, args, platform = process.platform, env = process.env) {
|
|
108
|
+
if (platform !== 'win32' || !/\.(cmd|bat)$/i.test(executable)) { return { executable, args }; }
|
|
109
|
+
const script = executable.replace(/\.(cmd|bat)$/i, '.ps1');
|
|
110
|
+
if (!exists(script)) { throw new Error('npm launcher has no PowerShell sibling; select a native CLI'); }
|
|
111
|
+
const command = `& ${[script, ...args].map(powerShellLiteral).join(' ')}`;
|
|
112
|
+
return { executable: path.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe'),
|
|
113
|
+
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command] };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// No prompts, model calls, login flows or provider output enter our report.
|
|
117
|
+
function probe(executable, args, rpc, options = {}) {
|
|
118
|
+
const env = runtimeEnvironment(options.env || process.env);
|
|
119
|
+
const platform = options.platform || process.platform;
|
|
120
|
+
return new Promise(resolve => {
|
|
121
|
+
const signals = options.signals || process;
|
|
122
|
+
const interrupted = () => { stop(); finish({ status: 'probe_cancelled' }); };
|
|
123
|
+
let child, timer, finished = false, output = '', pending = '', result;
|
|
124
|
+
function stop() {
|
|
125
|
+
if (!child || !child.pid) { return; }
|
|
126
|
+
if (platform === 'win32') {
|
|
127
|
+
execFile(path.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32/taskkill.exe'),
|
|
128
|
+
['/pid', String(child.pid), '/t', '/f'], { windowsHide: true, timeout: 3000 }, () => {});
|
|
129
|
+
} else {
|
|
130
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function finish(value) {
|
|
134
|
+
if (finished) { return; }
|
|
135
|
+
finished = true;
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
signals.removeListener('SIGINT', interrupted);
|
|
138
|
+
signals.removeListener('SIGTERM', interrupted);
|
|
139
|
+
resolve(value);
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const command = probeCommand(executable, args, platform, env);
|
|
143
|
+
child = (options.probeSpawn || spawn)(command.executable, command.args, {
|
|
144
|
+
env, cwd: os.tmpdir(), shell: false, windowsHide: true,
|
|
145
|
+
windowsVerbatimArguments: command.windowsVerbatimArguments,
|
|
146
|
+
detached: platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'],
|
|
147
|
+
});
|
|
148
|
+
signals.once('SIGINT', interrupted);
|
|
149
|
+
signals.once('SIGTERM', interrupted);
|
|
150
|
+
timer = setTimeout(() => { stop(); finish({ status: 'probe_timeout' }); }, options.timeoutMs || 15000);
|
|
151
|
+
child.on('error', () => finish({ status: 'launch_failed' }));
|
|
152
|
+
child.stdin.on('error', () => {});
|
|
153
|
+
child.stderr.on('data', chunk => { output = (output + chunk.toString()).slice(-65536); });
|
|
154
|
+
child.stdout.on('data', chunk => {
|
|
155
|
+
output = (output + chunk.toString()).slice(-65536);
|
|
156
|
+
if (!rpc || result) { return; }
|
|
157
|
+
pending += chunk.toString();
|
|
158
|
+
if (pending.length > 262144) { stop(); finish({ status: 'protocol_unsupported' }); return; }
|
|
159
|
+
let end;
|
|
160
|
+
while ((end = pending.indexOf('\n')) >= 0) {
|
|
161
|
+
const line = pending.slice(0, end); pending = pending.slice(end + 1);
|
|
162
|
+
try {
|
|
163
|
+
const message = JSON.parse(line);
|
|
164
|
+
if (message.id !== 1) { continue; }
|
|
165
|
+
const valid = rpc === 'qoder' ? message.result?.protocolVersion === 1 : typeof message.result?.userAgent === 'string';
|
|
166
|
+
result = { status: valid ? 'passed' : isLoginRequired(message.error?.message) ? 'login_required' : 'protocol_unsupported' };
|
|
167
|
+
stop();
|
|
168
|
+
finish(result);
|
|
169
|
+
} catch { /* Ignore non-protocol startup banners. */ }
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
child.on('close', code => finish(result || { status: rpc ? 'protocol_unsupported' : code === 0 ? 'passed' : 'probe_failed', output }));
|
|
173
|
+
if (rpc) {
|
|
174
|
+
const params = rpc === 'qoder'
|
|
175
|
+
? { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: 'openyida-preflight', version: '1' } }
|
|
176
|
+
: { clientInfo: { name: 'openyida-preflight', version: '1' }, capabilities: {} };
|
|
177
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params }) + '\n');
|
|
178
|
+
} else { child.stdin.end(); }
|
|
179
|
+
} catch { finish({ status: 'launch_failed' }); }
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isLoginRequired(output) {
|
|
184
|
+
return /\bnot logged in\b|\bnot authenticated\b|\bplease (?:log|sign) in\b/i.test(output || '');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function checkProvider(provider, executable, options) {
|
|
188
|
+
const execute = options.probe || probe;
|
|
189
|
+
const version = await execute(executable, ['--version'], null, options);
|
|
190
|
+
if (version.status !== 'passed') { return { status: version.status, cliDetected: false }; }
|
|
191
|
+
// Installation evidence is independent of authentication/protocol readiness.
|
|
192
|
+
// Apply this consistently to every enabled provider, including future adapters.
|
|
193
|
+
const detected = { cliDetected: true };
|
|
194
|
+
if (provider === 'opencode') { return { ...detected, status: 'available', authKnown: false, protocolProbe: 'not_run' }; }
|
|
195
|
+
const protocol = await execute(executable, provider === 'qoder' ? ['--acp'] : ['app-server', '--listen', 'stdio://'], provider, options);
|
|
196
|
+
if (protocol.status === 'login_required') {
|
|
197
|
+
return { ...detected, status: 'login_required', authKnown: true, protocolProbe: 'login_required' };
|
|
198
|
+
}
|
|
199
|
+
if (protocol.status !== 'passed') { return { ...detected, status: protocol.status === 'probe_failed' ? 'protocol_unsupported' : protocol.status }; }
|
|
200
|
+
const auth = await execute(executable, provider === 'qoder' ? ['--list-models'] : ['login', 'status'], null, options);
|
|
201
|
+
if (isLoginRequired(auth.output)) {
|
|
202
|
+
return { ...detected, status: 'login_required', authKnown: true, protocolProbe: 'passed' };
|
|
203
|
+
}
|
|
204
|
+
return { ...detected, status: auth.status === 'passed' ? 'ready' : auth.status, authKnown: auth.status === 'passed', protocolProbe: 'passed' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function remediation(provider) {
|
|
208
|
+
return {
|
|
209
|
+
action: 'INFORMATION_ONLY',
|
|
210
|
+
requiresExplicitUserRequest: true,
|
|
211
|
+
automaticActionAllowed: false,
|
|
212
|
+
documentationUrl: DOCS[provider],
|
|
213
|
+
providerPathOption: `--provider ${provider} --provider-path <absolute-cli-path>`,
|
|
214
|
+
retry: 'repeat_original_connect_command',
|
|
215
|
+
verifyCommand: 'openyida agent doctor --json',
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function preflight(args, options = {}) {
|
|
220
|
+
const platform = options.platform || process.platform;
|
|
221
|
+
const entries = args.provider && !PROVIDERS.includes(args.provider) ? [] : args.providerPath ? [{ provider: args.provider, candidates: [args.providerPath], desktopDetected: false }]
|
|
222
|
+
: inventory(options.env || process.env, platform, options.homedir).filter(p => !args.provider || p.provider === args.provider);
|
|
223
|
+
const providers = await Promise.all(entries.map(async entry => {
|
|
224
|
+
let result = { status: 'cli_not_found', cliDetected: false }, executable, discovered;
|
|
225
|
+
// Try a small number of installations; a stale PATH entry must not hide a working bundled CLI.
|
|
226
|
+
// Windows npm shims start PowerShell and Node for each of the three checks.
|
|
227
|
+
// A healthy cold CLI can take more than 15 seconds for a single handshake.
|
|
228
|
+
const timeoutMs = options.timeoutMs || (platform === 'win32' ? 45000 : 15000);
|
|
229
|
+
const deadline = Date.now() + (options.budgetMs || (platform === 'win32' ? 135000 : 30000));
|
|
230
|
+
const boundedProbe = async (exe, flags, rpc, opts) => {
|
|
231
|
+
const remaining = deadline - Date.now();
|
|
232
|
+
if (remaining <= 0) { return { status: 'probe_timeout' }; }
|
|
233
|
+
return (options.probe || probe)(exe, flags, rpc, { ...opts, timeoutMs: Math.min(timeoutMs, remaining) });
|
|
234
|
+
};
|
|
235
|
+
for (const candidate of entry.candidates.slice(0, 4)) {
|
|
236
|
+
executable = candidate;
|
|
237
|
+
result = await checkProvider(entry.provider, executable, { ...options, probe: boundedProbe });
|
|
238
|
+
if (result.cliDetected && !discovered) { discovered = { result, executable }; }
|
|
239
|
+
if (Date.now() >= deadline || ['ready', 'available', 'login_required', 'probe_cancelled'].includes(result.status)) { break; }
|
|
240
|
+
}
|
|
241
|
+
if (!result.cliDetected && result.status !== 'probe_cancelled' && discovered) {
|
|
242
|
+
({ result, executable } = discovered);
|
|
243
|
+
}
|
|
244
|
+
const usable = ['ready', 'available'].includes(result.status);
|
|
245
|
+
const registrable = result.cliDetected && result.status !== 'probe_cancelled';
|
|
246
|
+
return { provider: entry.provider, desktopDetected: entry.desktopDetected, executable, ...result, usable, registrable,
|
|
247
|
+
message: t(`agent.preflight_${result.status}`),
|
|
248
|
+
...(!usable ? { remediation: remediation(entry.provider) } : {}),
|
|
249
|
+
};
|
|
250
|
+
}));
|
|
251
|
+
return { schemaVersion: 1, type: 'preflight', platform, ready: providers.some(p => p.usable) && !providers.some(p => p.status === 'probe_cancelled'),
|
|
252
|
+
readyForEnrollment: providers.some(p => p.registrable) && !providers.some(p => p.status === 'probe_cancelled'),
|
|
253
|
+
paidPromptExecuted: false, advisoryOnly: true, automaticInstallAllowed: false,
|
|
254
|
+
guidance: t('agent.preflight_advisory'), providers };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = { discoverProviders, inventory, installationPaths, probeCommand, probe, preflight };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"keys": {
|
|
4
|
+
"openyida-runtime-public-beta-20260923-1": {
|
|
5
|
+
"publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEApl2Q+64rX997KxEQUY2yU8Xnc7sM3NRQ8Nf0bAi9Hck=\n-----END PUBLIC KEY-----\n"
|
|
6
|
+
}
|
|
7
|
+
},
|
|
8
|
+
"releases": [
|
|
9
|
+
{
|
|
10
|
+
"platform": "darwin",
|
|
11
|
+
"arch": "arm64",
|
|
12
|
+
"packageName": "@openyida/local-agent-runtime-darwin-arm64",
|
|
13
|
+
"packageVersion": "0.1.1",
|
|
14
|
+
"runtimeVersion": "0.1.1",
|
|
15
|
+
"manifestSha256": "7f3d8e2596210b50db6b664db902f1036a1d51a87fd80e298a50d337d6a44205"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"platform": "darwin",
|
|
19
|
+
"arch": "x64",
|
|
20
|
+
"packageName": "@openyida/local-agent-runtime-darwin-x64",
|
|
21
|
+
"packageVersion": "0.1.1",
|
|
22
|
+
"runtimeVersion": "0.1.1",
|
|
23
|
+
"manifestSha256": "e4922eaff8f0000737ede59a7beeb30f88c8a2c97f7a93c871bfe9938d16dc3d"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"platform": "win32",
|
|
27
|
+
"arch": "x64",
|
|
28
|
+
"packageName": "@openyida/local-agent-runtime-win32-x64",
|
|
29
|
+
"packageVersion": "0.1.1",
|
|
30
|
+
"runtimeVersion": "0.1.1",
|
|
31
|
+
"manifestSha256": "3f18fd2f73870e96af8d6eab8edb7594575cf870f90ac9cf1d1506f03f854023"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"platform": "win32",
|
|
35
|
+
"arch": "arm64",
|
|
36
|
+
"packageName": "@openyida/local-agent-runtime-win32-arm64",
|
|
37
|
+
"packageVersion": "0.1.1",
|
|
38
|
+
"runtimeVersion": "0.1.1",
|
|
39
|
+
"manifestSha256": "0af4afe0bb61466e702500609948b0c3ad24546fe2f80de31cf20ff86ad87266"
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|