cli-validator 7.0.38 → 7.0.39

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 CHANGED
@@ -79,3 +79,17 @@ Use a source fixture containing only the needed first-party modules, not a direc
79
79
  仅在 TLS 握手前确定尚未发送 HTTP 请求时,broker 才允许最多 3 次连接尝试,并受总超时约束。请求发出后发生断线或响应中断,只用 GET 查询原 requestId 的服务端回执,禁止重发 POST;未取得有效回执时保留不确定状态,不得假定成功或继续依赖步骤。
80
80
 
81
81
  `npx cli-validator@latest recover <operation> <requestId>` 可重新查询原调用,不会重做操作或重复计费。链恢复不会跳过人工确认,也不会自动重跑结果不确定的本地命令。代理连接需 Node.js 22.21+ 或 24.5+;不支持的运行时会明确报错。
82
+
83
+ ## 账号共享凭据与自动更新
84
+
85
+ 在已登录的能力市场复制安装入口,将内容粘贴给 IDE。页面只展示原地址,剪贴板会携带当前账号凭据。IDE 将四字段凭据 JSON 经标准输入交给 `npx cli-aimlock@latest configure`;不要放到命令参数、项目文件或日志中。一次配置供同一操作系统账号的所有项目、分支和任务使用,八个技能共享同一文件。
86
+
87
+ 默认位置:macOS 为 `~/Library/Application Support/CLI.Tax/broker/credential.json`,Linux 为 `~/.local/share/CLI.Tax/broker/credential.json`,Windows 为 `%LOCALAPPDATA%\CLI.Tax\broker\credential.json`。显式 `CLITAX_BRAIN_CLIENT_TOKEN_FILE` 仍按绝对路径覆盖默认位置;迁移旧 IDE 配置时移除其过时覆盖,再使用账号共享文件。macOS/Linux 校验当前账号所有权和0600权限;Windows校验仅当前账号与SYSTEM可访问的ACL。
88
+
89
+ 每次新技能调用先查询官方发布版本,精确版本下载并校验身份后自动使用;更新已托管的当前项目与账号技能目录,失败恢复旧目录,禁止覆盖 Git 跟踪源码或未托管内容。升级返回 `upgrade.reloadRequired` 和说明路径时,IDE 应读取更新后的 SKILL.md、核对本任务合同再继续。install/check同样自动更新,不需要每次人工发升级指令。查询不确定调用的原回执不升级、不重发操作。
90
+
91
+ 升级不会清除账号凭据;各调用重新读取共享文件,因此重新同步一次密钥后所有任务使用新值。已撤销或失效的密钥不能为自己取得新权限,必须从已认证网页重新同步一次。两个不同操作系统账号不共享私密文件。
92
+
93
+ English: configure once using JSON stdin; all tasks under the same OS account reuse the credential. Each new invocation checks and updates the official package and managed documentation. Reload updated instructions when indicated. Revoked keys require a fresh authenticated copy.
94
+
95
+ Русский: настройте ключ один раз через JSON stdin для всех задач пользователя ОС. Перед новым вызовом пакет и управляемые инструкции обновляются автоматически. Отозванный ключ требует повторной синхронизации с авторизованной страницы.
@@ -0,0 +1,135 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { lstat, mkdir, readFile, rm } from 'node:fs/promises'
3
+ import { dirname, join, parse, resolve, win32 } from 'node:path'
4
+ import { userInfo } from 'node:os'
5
+ import { promisify } from 'node:util'
6
+ import { randomUUID } from 'node:crypto'
7
+
8
+ const runFile = promisify(execFile)
9
+ const DIRECTORY_MODE = 0o700
10
+ const FILE_MODE = 0o600
11
+ const SYSTEM_SID = 'S-1-5-18'
12
+ const ACL_SID_ALIASES = Object.freeze({ SY: SYSTEM_SID, WD: 'S-1-1-0', BA: 'S-1-5-32-544',
13
+ BU: 'S-1-5-32-545', AU: 'S-1-5-11', CO: 'S-1-3-0', CG: 'S-1-3-1', AN: 'S-1-5-7' })
14
+ const SID_PATTERN = /^S-1-(?:[0-9]+-)*[0-9]+$/
15
+ const ACL_TIMEOUT_MS = 15_000
16
+
17
+ export function currentAccountHome() {
18
+ const home = userInfo().homedir
19
+ if (typeof home !== 'string' || !parse(home).root) throw new Error('The current account has no absolute home directory')
20
+ return home
21
+ }
22
+
23
+ export async function assertAccountAncestors(path, platform = process.platform) {
24
+ const paths = platform === 'win32' ? win32 : { dirname, resolve, parse }
25
+ let current = paths.resolve(path)
26
+ const ancestors = []
27
+ while (current !== paths.parse(current).root) {
28
+ ancestors.unshift(current)
29
+ current = paths.dirname(current)
30
+ }
31
+ for (const ancestor of ancestors) {
32
+ let status
33
+ try { status = await lstat(ancestor) } catch (error) {
34
+ if (error.code === 'ENOENT') continue
35
+ throw error
36
+ }
37
+ if (status.isSymbolicLink() || !status.isDirectory()) throw new Error('Account storage cannot traverse symlink or non-directory parents')
38
+ }
39
+ }
40
+
41
+ function aclText(bytes) {
42
+ return bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe
43
+ ? bytes.subarray(2).toString('utf16le') : bytes.toString('utf8')
44
+ }
45
+
46
+ export function windowsAclEntries(text) {
47
+ const descriptor = text.split(/\r?\n/).find(line => line.startsWith('D:'))
48
+ if (!descriptor) throw new Error('Windows ACL descriptor is missing')
49
+ const entries = [...descriptor.matchAll(/\(([^()]*)\)/g)].map(match => {
50
+ const fields = match[1].split(';')
51
+ if (fields.length !== 6) throw new Error('Windows ACL entry is invalid')
52
+ return { type: fields[0], flags: fields[1], rights: fields[2], sid: fields[5] }
53
+ })
54
+ if (!entries.length) throw new Error('Windows ACL cannot be empty')
55
+ return { descriptor, entries }
56
+ }
57
+
58
+ export function assertRestrictedWindowsAcl(text, ownerSid) {
59
+ const { descriptor, entries } = windowsAclEntries(text)
60
+ if (!descriptor.startsWith('D:P') || entries.length !== 2) throw new Error('Windows account ACL must be protected and grant only the user and SYSTEM')
61
+ const expected = new Set([ownerSid, SYSTEM_SID])
62
+ for (const entry of entries) {
63
+ const sid = Object.hasOwn(ACL_SID_ALIASES, entry.sid) ? ACL_SID_ALIASES[entry.sid] : entry.sid
64
+ if (entry.type !== 'A' || !['FA', '0x1f01ff'].includes(entry.rights)
65
+ || entry.flags.replaceAll('OI', '').replaceAll('CI', '') !== '' || !expected.delete(sid)) {
66
+ throw new Error('Windows account ACL contains unexpected access')
67
+ }
68
+ }
69
+ if (expected.size) throw new Error('Windows account ACL is missing the user or SYSTEM')
70
+ }
71
+
72
+ async function windowsOwnerSid(run) {
73
+ const result = await run('whoami.exe', ['/user', '/fo', 'csv', '/nh'], { windowsHide: true, timeout: ACL_TIMEOUT_MS })
74
+ const candidates = result.stdout.match(/S-1-(?:[0-9]+-)*[0-9]+/g)
75
+ if (candidates === null || candidates.length !== 1 || !SID_PATTERN.test(candidates[0])) throw new Error('Current Windows account SID could not be verified')
76
+ return candidates[0]
77
+ }
78
+
79
+ async function readWindowsAcl(path, run) {
80
+ const temporary = join(dirname(path), '.acl-' + randomUUID() + '.txt')
81
+ try {
82
+ await run('icacls.exe', [path, '/save', temporary, '/q'], { windowsHide: true, timeout: ACL_TIMEOUT_MS })
83
+ return aclText(await readFile(temporary))
84
+ } finally {
85
+ try { await rm(temporary) } catch (error) { if (error.code !== 'ENOENT') throw error }
86
+ }
87
+ }
88
+
89
+ async function protectWindowsPath(path, directory, dependencies) {
90
+ const run = dependencies.execFile === undefined ? runFile : dependencies.execFile
91
+ const owner = await windowsOwnerSid(run)
92
+ const flags = directory ? '(OI)(CI)F' : 'F'
93
+ await run('icacls.exe', [path, '/inheritance:r', '/grant:r', '*' + owner + ':' + flags,
94
+ '*' + SYSTEM_SID + ':' + flags], { windowsHide: true, timeout: ACL_TIMEOUT_MS })
95
+ const { entries } = windowsAclEntries(await readWindowsAcl(path, run))
96
+ for (const entry of entries) {
97
+ const sid = Object.hasOwn(ACL_SID_ALIASES, entry.sid) ? ACL_SID_ALIASES[entry.sid] : entry.sid
98
+ if (entry.type === 'A' && [owner, SYSTEM_SID].includes(sid)) continue
99
+ if (!SID_PATTERN.test(sid)) throw new Error('Unexpected Windows ACL trustee')
100
+ await run('icacls.exe', [path, entry.type === 'D' ? '/remove:d' : '/remove:g', '*' + sid],
101
+ { windowsHide: true, timeout: ACL_TIMEOUT_MS })
102
+ }
103
+ assertRestrictedWindowsAcl(await readWindowsAcl(path, run), owner)
104
+ }
105
+
106
+ export async function protectAccountPath(path, directory, dependencies = {}) {
107
+ const platform = dependencies.platform === undefined ? process.platform : dependencies.platform
108
+ await assertAccountAncestors(dirname(path), platform)
109
+ const status = await lstat(path)
110
+ if (status.isSymbolicLink() || (directory ? !status.isDirectory() : !status.isFile())) throw new Error('Account storage object has an unsafe type')
111
+ if (platform === 'win32') return protectWindowsPath(path, directory, dependencies)
112
+ const mode = directory ? DIRECTORY_MODE : FILE_MODE
113
+ if (status.uid !== process.getuid() || (status.mode & 0o777) !== mode) throw new Error('Account storage must be owned by the current account with restricted permissions')
114
+ }
115
+
116
+ export async function ensureAccountDirectory(path, dependencies = {}) {
117
+ const platform = dependencies.platform === undefined ? process.platform : dependencies.platform
118
+ await assertAccountAncestors(path, platform)
119
+ await mkdir(path, { recursive: true, mode: DIRECTORY_MODE })
120
+ await protectAccountPath(path, true, dependencies)
121
+ }
122
+
123
+ export async function verifyAccountPath(path, dependencies = {}) {
124
+ const platform = dependencies.platform === undefined ? process.platform : dependencies.platform
125
+ await assertAccountAncestors(dirname(path), platform)
126
+ const status = await lstat(path)
127
+ if (!status.isFile() || status.isSymbolicLink()) throw new Error('Credential must be a regular account file')
128
+ if (platform !== 'win32') {
129
+ if (status.uid !== process.getuid() || (status.mode & 0o777) !== FILE_MODE) throw new Error('Credential must be owned by the account with mode 0600')
130
+ return
131
+ }
132
+ const run = dependencies.execFile === undefined ? runFile : dependencies.execFile
133
+ const owner = await windowsOwnerSid(run)
134
+ assertRestrictedWindowsAcl(await readWindowsAcl(path, run), owner)
135
+ }
@@ -0,0 +1,125 @@
1
+ import { constants } from 'node:fs'
2
+ import { lstat, open, rename, rm } from 'node:fs/promises'
3
+ import { currentAccountHome, ensureAccountDirectory, protectAccountPath, verifyAccountPath } from './broker-account-storage.mjs'
4
+ import { isAbsolute, join, resolve, win32 } from 'node:path'
5
+ import { randomUUID } from 'node:crypto'
6
+
7
+ const TOKEN_FILE_ENV = 'CLITAX_BRAIN_CLIENT_TOKEN_FILE'
8
+ const TOKEN_FILE_VERSION = 'member-brain.client-token-file/1.0'
9
+ const TOKEN_FILE_MAX_BYTES = 16_384
10
+ const AUTH_SCHEME = 'BrainClient'
11
+ const TOKEN_ENDPOINT = 'https://cli.tax/api/v1/telemetry/skill-usage'
12
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/
13
+ const TOKEN_MODE = 0o600
14
+
15
+ export function accountBrokerDirectory(environment, platform = process.platform, home = currentAccountHome()) {
16
+ if (platform === 'win32') {
17
+ if (typeof environment.LOCALAPPDATA !== 'string' || !win32.isAbsolute(environment.LOCALAPPDATA)) {
18
+ throw new Error('LOCALAPPDATA must identify the current account directory')
19
+ }
20
+ const relative = win32.relative(home, environment.LOCALAPPDATA)
21
+ if (relative === '..' || relative.startsWith('..\\') || win32.isAbsolute(relative)) throw new Error('LOCALAPPDATA must belong to the current account home')
22
+ return win32.join(environment.LOCALAPPDATA, 'CLI.Tax', 'broker')
23
+ }
24
+ if (!isAbsolute(home)) throw new Error('Account home directory must be absolute')
25
+ return platform === 'darwin' ? join(home, 'Library', 'Application Support', 'CLI.Tax', 'broker')
26
+ : join(home, '.local', 'share', 'CLI.Tax', 'broker')
27
+ }
28
+
29
+ export function brainClientTokenPath(environment, platform = process.platform, home = currentAccountHome()) {
30
+ const configured = environment[TOKEN_FILE_ENV]
31
+ const directory = accountBrokerDirectory(environment, platform, home)
32
+ if (configured === undefined) return platform === 'win32'
33
+ ? win32.join(directory, 'credential.json') : join(directory, 'credential.json')
34
+ if (typeof configured !== 'string' || !configured.trim()) throw new Error(TOKEN_FILE_ENV + ' must be an absolute path')
35
+ const candidate = configured.trim()
36
+ if (platform !== 'win32') {
37
+ if (!isAbsolute(candidate)) throw new Error(TOKEN_FILE_ENV + ' must be absolute and independent of the project directory')
38
+ return resolve(candidate)
39
+ }
40
+ if (!win32.isAbsolute(candidate)) throw new Error('Windows Brain Client token file path must be absolute')
41
+ const path = win32.resolve(candidate), relative = win32.relative(directory, path)
42
+ if (relative === '..' || relative.startsWith('..\\') || win32.isAbsolute(relative)) {
43
+ throw new Error('Windows Brain Client token file must be inside its account broker directory')
44
+ }
45
+ return path
46
+ }
47
+
48
+ export function validateBrainClientCredential(value, endpoint = TOKEN_ENDPOINT) {
49
+ if (!value || typeof value !== 'object' || Array.isArray(value)
50
+ || Object.keys(value).sort().join(',') !== 'authorizationScheme,endpoint,schemaVersion,token') {
51
+ throw new Error('Brain Client credential has unknown or missing fields')
52
+ }
53
+ if (value.schemaVersion !== TOKEN_FILE_VERSION || value.authorizationScheme !== AUTH_SCHEME
54
+ || value.endpoint !== TOKEN_ENDPOINT || new URL(endpoint).origin !== new URL(TOKEN_ENDPOINT).origin
55
+ || typeof value.token !== 'string' || !TOKEN_PATTERN.test(value.token)) {
56
+ throw new Error('Brain Client token file authority is invalid')
57
+ }
58
+ return value
59
+ }
60
+
61
+ function parseCredential(source) {
62
+ if (Buffer.byteLength(source) > TOKEN_FILE_MAX_BYTES) throw new Error('Brain Client credential exceeds the size limit')
63
+ let parsed
64
+ try { parsed = JSON.parse(source) } catch { throw new Error('Brain Client credential must contain valid JSON') }
65
+ return validateBrainClientCredential(parsed)
66
+ }
67
+
68
+ function assertRestrictedFile(status, platform, currentUserId) {
69
+ if (!status.isFile() || status.size < 1 || status.size > TOKEN_FILE_MAX_BYTES) {
70
+ throw new Error('Brain Client token file must be a non-empty restricted file')
71
+ }
72
+ if (platform === 'win32') return
73
+ if (!Number.isInteger(currentUserId) || status.uid !== currentUserId || (status.mode & 0o777) !== TOKEN_MODE) {
74
+ throw new Error('Brain Client token file must be owned by the current user with mode 0600')
75
+ }
76
+ }
77
+
78
+ export async function brainClientAuthorization(context, environment, dependencies = {}) {
79
+ const platform = dependencies.platform === undefined ? process.platform : dependencies.platform
80
+ const path = brainClientTokenPath(environment, platform, dependencies.homeDirectory === undefined ? currentAccountHome() : dependencies.homeDirectory)
81
+ const inspect = dependencies.lstat === undefined ? lstat : dependencies.lstat
82
+ const openFile = dependencies.open === undefined ? open : dependencies.open
83
+ const currentUserId = platform === 'win32' ? null : (dependencies.getuid === undefined ? process.getuid : dependencies.getuid)()
84
+ let status
85
+ try { status = await inspect(path) } catch (error) {
86
+ if (error.code === 'ENOENT') throw new Error('Brain Client credential is not configured; copy the authenticated setup from CLI.Tax and run configure with JSON stdin')
87
+ throw error
88
+ }
89
+ if (status.isSymbolicLink()) throw new Error('Brain Client token file cannot be a symlink')
90
+ const verifyPath = dependencies.verifyPath === undefined ? verifyAccountPath : dependencies.verifyPath
91
+ await verifyPath(path, { ...dependencies, platform })
92
+ const handle = await openFile(path, constants.O_RDONLY | (platform === 'win32' ? 0 : constants.O_NOFOLLOW))
93
+ try {
94
+ assertRestrictedFile(await handle.stat(), platform, currentUserId)
95
+ const value = validateBrainClientCredential(parseCredential(await handle.readFile('utf8')), context.endpoint)
96
+ return AUTH_SCHEME + ' ' + value.token
97
+ } finally { await handle.close() }
98
+ }
99
+
100
+ export async function configureBrainClientCredential(source, environment = process.env, dependencies = {}) {
101
+ const credential = parseCredential(source)
102
+ const platform = dependencies.platform === undefined ? process.platform : dependencies.platform
103
+ const home = dependencies.homeDirectory === undefined ? currentAccountHome() : dependencies.homeDirectory
104
+ const directory = accountBrokerDirectory(environment, platform, home)
105
+ await ensureAccountDirectory(directory, { ...dependencies, platform })
106
+ const path = join(directory, 'credential.json')
107
+ try { await protectAccountPath(path, false, { ...dependencies, platform }) }
108
+ catch (error) { if (error.code !== 'ENOENT') throw error }
109
+ const temporary = join(directory, 'credential-' + randomUUID() + '.json')
110
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL
111
+ | (platform === 'win32' ? 0 : constants.O_NOFOLLOW), TOKEN_MODE)
112
+ try {
113
+ await handle.writeFile(JSON.stringify(credential) + '\n')
114
+ await handle.sync()
115
+ } finally { await handle.close() }
116
+ try {
117
+ await protectAccountPath(temporary, false, { ...dependencies, platform })
118
+ await rename(temporary, path)
119
+ await protectAccountPath(path, false, { ...dependencies, platform })
120
+ } catch (error) {
121
+ try { await rm(temporary) } catch (cleanupError) { if (cleanupError.code !== 'ENOENT') throw cleanupError }
122
+ throw error
123
+ }
124
+ return { configured: true, path, scope: 'current-account', requiresEnvironmentOverrideRemoval: environment[TOKEN_FILE_ENV] !== undefined }
125
+ }
package/broker.mjs CHANGED
@@ -1,25 +1,17 @@
1
1
  import { createHash, randomUUID } from 'node:crypto'
2
- import { constants } from 'node:fs'
3
- import { lstat, open } from 'node:fs/promises'
4
- import { resolve, win32 } from 'node:path'
2
+ import { brainClientAuthorization } from './broker-credentials.mjs'
3
+ import { prepareOfficialSkillUse, withUpgradeMetadata } from './official-skill-update.mjs'
4
+ export { brainClientAuthorization, brainClientTokenPath } from './broker-credentials.mjs'
5
+ export { LOOKUP_TIMEOUT_MS } from './official-skill-update.mjs'
5
6
  import { OfficialSkillInvocationError, OfficialSkillResponseError, transportFailureCode, transportDiagnostics } from './broker-failures.mjs'
6
7
  import { queryOfficialSkillReceipt, SKILL_RECEIPT_HEADER, SKILL_RECEIPT_SCHEMA } from './broker-recovery.mjs'
7
8
  export { officialSkillFailureResponse, transportFailureCode } from './broker-failures.mjs'
8
9
 
9
- export const LOOKUP_TIMEOUT_MS = 8000
10
10
  export const CALL_TIMEOUT_MS = 120_000
11
- const FEEDBACK_API_PATH = '/api/v1/telemetry/skill-usage'
12
- const TOKEN_FILE_ENV = 'CLITAX_BRAIN_CLIENT_TOKEN_FILE'
13
- const TOKEN_FILE_VERSION = 'member-brain.client-token-file/1.0'
14
- const AUTH_SCHEME = 'BrainClient'
15
- const TOKEN_FILE_MAX_BYTES = 16_384
16
- const POSIX_TOKEN_FILE_MODE = 0o600
17
- const WINDOWS_BROKER_DIRECTORY = ['CLI.Tax', 'broker']
18
11
  const FEEDBACK_COMMENT_MAX = 500
19
12
  const EVALUATION_DURATION_MAX = 86_400_000
20
13
  const SCORE_MIN = 0
21
14
  const SCORE_MAX = 100
22
- const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/
23
15
  const INVOCATION_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
24
16
  const DIGEST_PATTERN = /^[0-9a-f]{64}$/
25
17
  const PROTOCOL_STATUSES = new Set(['succeeded', 'blocked', 'failed'])
@@ -48,84 +40,6 @@ function boundedInteger(value, label) {
48
40
  return value
49
41
  }
50
42
 
51
- function insideWindowsDirectory(candidate, directory) {
52
- const relative = win32.relative(directory, candidate)
53
- return relative === '' || (!relative.startsWith('..\\') && relative !== '..' && !win32.isAbsolute(relative))
54
- }
55
-
56
- export function brainClientTokenPath(environment, platform = process.platform) {
57
- const configured = requiredString(environment[TOKEN_FILE_ENV], TOKEN_FILE_ENV)
58
- if (platform !== 'win32') return resolve(configured)
59
- if (!win32.isAbsolute(configured)) {
60
- throw new Error('Windows Brain Client token file path must be absolute')
61
- }
62
- const localAppData = requiredString(environment.LOCALAPPDATA, 'LOCALAPPDATA')
63
- const brokerDirectory = win32.resolve(localAppData, ...WINDOWS_BROKER_DIRECTORY)
64
- const candidate = win32.resolve(configured)
65
- if (!insideWindowsDirectory(candidate, brokerDirectory)) {
66
- throw new Error(`Windows Brain Client token file must be inside ${brokerDirectory}`)
67
- }
68
- return candidate
69
- }
70
-
71
- function assertTokenFileStatus(status, platform, currentUserId) {
72
- if (!status.isFile() || status.size < 1 || status.size > TOKEN_FILE_MAX_BYTES) {
73
- throw new Error('Brain Client token file must be a non-empty restricted file')
74
- }
75
- if (platform === 'win32') return
76
- if (!Number.isInteger(currentUserId)) {
77
- throw new Error('Brain Client token file ownership cannot be verified')
78
- }
79
- if (status.uid !== currentUserId || (status.mode & 0o777) !== POSIX_TOKEN_FILE_MODE) {
80
- throw new Error('Brain Client token file must be owned by the current user with mode 0600')
81
- }
82
- }
83
-
84
- function parseTokenFile(source) {
85
- let tokenFile
86
- try {
87
- tokenFile = asObject(JSON.parse(source), 'Brain Client token file')
88
- } catch {
89
- throw new Error('Brain Client token file must contain valid JSON')
90
- }
91
- const expectedKeys = ['authorizationScheme', 'endpoint', 'schemaVersion', 'token']
92
- if (Object.keys(tokenFile).sort().join('\n') !== expectedKeys.join('\n')) {
93
- throw new Error('Brain Client token file contains unknown or missing fields')
94
- }
95
- return tokenFile
96
- }
97
-
98
- export async function brainClientAuthorization(context, environment, dependencies = {}) {
99
- const platform = dependencies.platform ?? process.platform
100
- const tokenFilePath = brainClientTokenPath(environment, platform)
101
- const inspectPath = dependencies.lstat ?? lstat
102
- const openPath = dependencies.open ?? open
103
- const currentUserId = platform === 'win32'
104
- ? null
105
- : (dependencies.getuid ?? process.getuid)?.()
106
- const linkStatus = await inspectPath(tokenFilePath)
107
- if (linkStatus.isSymbolicLink()) throw new Error('Brain Client token file cannot be a symlink')
108
- const noFollow = platform === 'win32' ? 0 : (constants.O_NOFOLLOW ?? 0)
109
- const handle = await openPath(tokenFilePath, constants.O_RDONLY | noFollow)
110
- try {
111
- const status = await handle.stat()
112
- assertTokenFileStatus(status, platform, currentUserId)
113
- const tokenFile = parseTokenFile(await handle.readFile('utf8'))
114
- const endpoint = new URL(requiredString(tokenFile.endpoint, 'Brain Client endpoint'))
115
- if (tokenFile.schemaVersion !== TOKEN_FILE_VERSION
116
- || tokenFile.authorizationScheme !== AUTH_SCHEME
117
- || endpoint.origin !== new URL(context.endpoint).origin
118
- || endpoint.pathname !== FEEDBACK_API_PATH || endpoint.search || endpoint.hash
119
- || endpoint.username || endpoint.password
120
- || !TOKEN_PATTERN.test(tokenFile.token)) {
121
- throw new Error('Brain Client token file authority is invalid')
122
- }
123
- return `${AUTH_SCHEME} ${tokenFile.token}`
124
- } finally {
125
- await handle.close()
126
- }
127
- }
128
-
129
43
  function canonicalJson(value) {
130
44
  if (value === null || typeof value === 'string' || typeof value === 'boolean') {
131
45
  return JSON.stringify(value)
@@ -205,6 +119,9 @@ export function authoritativeEvaluation(value, expected) {
205
119
 
206
120
  async function responsePayload(response, context, request) {
207
121
  const label = `${context.displayName} ${request.operation} response`
122
+ if (response.status === 401 || response.status === 403) {
123
+ throw new OfficialSkillResponseError(request, 'http-response', 'Brain Client authorization was rejected; copy the current authenticated setup from CLI.Tax and run configure again. Revoked credentials cannot renew themselves.')
124
+ }
208
125
  let payload
209
126
  try {
210
127
  payload = await response.json()
@@ -235,6 +152,9 @@ function invocationRequest(context, operation, input) {
235
152
  }
236
153
 
237
154
  export async function invokeOfficialSkill(context, operation, input, dependencies) {
155
+ const prepared = await prepareOfficialSkillUse(context, 'broker.mjs', dependencies)
156
+ if (prepared.module !== null) return withUpgradeMetadata(
157
+ await prepared.module.invokeOfficialSkill(prepared.context, operation, input, dependencies), prepared.upgrade)
238
158
  const environment = asObject(dependencies.environment, 'broker environment')
239
159
  if (typeof dependencies.request !== 'function') {
240
160
  throw new Error('broker request dependency is required')
@@ -252,14 +172,14 @@ export async function invokeOfficialSkill(context, operation, input, dependencie
252
172
  })
253
173
  } catch (error) {
254
174
  const failure = new OfficialSkillInvocationError(context, requestEnvelope, transportFailureCode(error), 'request', error?.transport)
255
- return recoverTransportFailure(context, requestEnvelope, dependencies, authorization, failure)
175
+ return withUpgradeMetadata(await recoverTransportFailure(context, requestEnvelope, dependencies, authorization, failure), prepared.upgrade)
256
176
  }
257
177
  let payload
258
178
  try {
259
179
  payload = await responsePayload(response, context, requestEnvelope)
260
180
  } catch (error) {
261
181
  if (!(error instanceof OfficialSkillInvocationError)) throw error
262
- return recoverTransportFailure(context, requestEnvelope, dependencies, authorization, error)
182
+ return withUpgradeMetadata(await recoverTransportFailure(context, requestEnvelope, dependencies, authorization, error), prepared.upgrade)
263
183
  }
264
184
  if (!response.ok || payload.ok !== true) {
265
185
  throw new OfficialSkillResponseError(requestEnvelope, 'http-response', `${context.displayName} ${operation} failed: HTTP ${response.status}`)
@@ -267,7 +187,7 @@ export async function invokeOfficialSkill(context, operation, input, dependencie
267
187
  try {
268
188
  const invocation = validateInvocationResponse(context, operation, payload, requestEnvelope)
269
189
  const transport = transportDiagnostics(response.transport)
270
- return transport ? { ...invocation, transport } : invocation
190
+ return withUpgradeMetadata(transport ? { ...invocation, transport } : invocation, prepared.upgrade)
271
191
  } catch (error) {
272
192
  throw new OfficialSkillResponseError(requestEnvelope, 'response-validation',
273
193
  error instanceof Error ? error.message : 'Skill response validation failed')
@@ -0,0 +1,205 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { existsSync, readFileSync } from 'node:fs'
4
+ import { cp, lstat, mkdir, readdir, rename, rm, writeFile } from 'node:fs/promises'
5
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
6
+ import { promisify } from 'node:util'
7
+ import { assertAccountAncestors, currentAccountHome } from './broker-account-storage.mjs'
8
+
9
+ const runFile = promisify(execFile)
10
+ const INSTALL_META = 'install-meta.json'
11
+ const MAX_SKILL_FILES = 256
12
+ const GIT_TIMEOUT_MS = 8000
13
+
14
+ function object(value, label) {
15
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(label + ' must be an object')
16
+ return value
17
+ }
18
+
19
+ export function readInstallMeta(target) {
20
+ const path = join(target, INSTALL_META)
21
+ if (!existsSync(path)) return null
22
+ return object(JSON.parse(readFileSync(path, 'utf8')), INSTALL_META)
23
+ }
24
+
25
+ export function installTarget(skillName, explicit, environment = process.env, workingDirectory = process.cwd()) {
26
+ if (explicit !== undefined) {
27
+ if (typeof explicit !== 'string' || !explicit.trim()) throw new Error('Install directory must be non-empty')
28
+ return resolve(explicit)
29
+ }
30
+ if (environment.CODEX_HOME !== undefined) {
31
+ if (typeof environment.CODEX_HOME !== 'string' || !isAbsolute(environment.CODEX_HOME)) throw new Error('CODEX_HOME must be absolute')
32
+ return join(environment.CODEX_HOME, 'skills', skillName)
33
+ }
34
+ return join(workingDirectory, '.codex', 'skills', skillName)
35
+ }
36
+
37
+ function contained(root, path) {
38
+ const inside = relative(root, path)
39
+ return inside === '' || (!inside.startsWith('..' + '/') && inside !== '..' && !isAbsolute(inside)
40
+ && !inside.startsWith('..' + '\\'))
41
+ }
42
+
43
+ function sameIdentity(meta, context) {
44
+ return meta.source === context.runtimeCode && meta.slug === context.skillName && meta.endpoint === context.endpoint
45
+ && typeof meta.version === 'string' && /^v?\d+\.\d+\.\d+$/.test(meta.version)
46
+ && typeof meta.packageVersion === 'string' && /^\d+\.\d+\.\d+$/.test(meta.packageVersion)
47
+ }
48
+
49
+ function newerVersion(installed, selected) {
50
+ const parts = version => {
51
+ if (typeof version !== 'string' || !/^v?\d+\.\d+\.\d+$/.test(version)) {
52
+ throw new Error('Managed skill comparison requires a semantic release version')
53
+ }
54
+ return version.replace(/^v/, '').split('.').map(value => BigInt(value))
55
+ }
56
+ const left = parts(installed), right = parts(selected)
57
+ const index = left.findIndex((part, position) => part !== right[position])
58
+ return index !== -1 && left[index] > right[index]
59
+ }
60
+
61
+ function assertNoManagedDowngrade(previous, context) {
62
+ if (previous !== null && (newerVersion(previous.version, context.skillVersion)
63
+ || newerVersion(previous.packageVersion, context.packageVersion))) {
64
+ throw new Error('The managed skill is newer than the selected release; refusing to downgrade its installation')
65
+ }
66
+ }
67
+
68
+ async function targetExists(path) {
69
+ try { return await lstat(path) } catch (error) { if (error.code === 'ENOENT') return null; throw error }
70
+ }
71
+
72
+ async function trackedSource(target) {
73
+ let current = dirname(target)
74
+ for (;;) {
75
+ if (await targetExists(join(current, '.git')) !== null) {
76
+ try {
77
+ const result = await runFile('git', ['-C', current, 'ls-files', '--', relative(current, target)],
78
+ { timeout: GIT_TIMEOUT_MS, maxBuffer: 1_048_576 })
79
+ return result.stdout.trim().length > 0
80
+ } catch { throw new Error('Cannot verify whether the install target contains repository source files') }
81
+ }
82
+ const parent = dirname(current)
83
+ if (parent === current) return false
84
+ current = parent
85
+ }
86
+ }
87
+
88
+ async function managedTarget(context, target, allowCreate) {
89
+ const path = resolve(target)
90
+ await assertAccountAncestors(dirname(path))
91
+ if (contained(resolve(context.packageRoot), path) || contained(path, resolve(context.packageRoot))) {
92
+ return { path, status: 'skipped', reason: 'package-source' }
93
+ }
94
+ const status = await targetExists(path)
95
+ if (status === null) return allowCreate ? { path, status: 'new', meta: null }
96
+ : { path, status: 'skipped', reason: 'not-installed' }
97
+ if (!status.isDirectory() || status.isSymbolicLink()) throw new Error('Installed skill target must be a regular directory')
98
+ if (await trackedSource(path)) return { path, status: 'skipped', reason: 'repository-source' }
99
+ const metaStatus = await targetExists(join(path, INSTALL_META))
100
+ if (metaStatus === null) return { path, status: 'skipped', reason: 'unmanaged' }
101
+ if (!metaStatus.isFile() || metaStatus.isSymbolicLink()) throw new Error('Install metadata must be a regular file')
102
+ if (process.platform !== 'win32' && (status.uid !== process.getuid() || metaStatus.uid !== process.getuid())) throw new Error('Managed skill installation must belong to the current account')
103
+ const meta = readInstallMeta(path)
104
+ if (!sameIdentity(meta, context)) return { path, status: 'skipped', reason: 'identity-mismatch' }
105
+ return { path, status: 'managed', meta }
106
+ }
107
+
108
+ async function verifySkillSource(directory, context) {
109
+ let files = 0
110
+ async function visit(path) {
111
+ const status = await lstat(path)
112
+ if (status.isSymbolicLink()) throw new Error('Skill package cannot contain symbolic links')
113
+ if (status.isDirectory()) {
114
+ for (const entry of await readdir(path)) await visit(join(path, entry))
115
+ return
116
+ }
117
+ if (!status.isFile() || ++files > MAX_SKILL_FILES) throw new Error('Skill package contains an unsupported or excessive file set')
118
+ }
119
+ await assertAccountAncestors(directory)
120
+ await visit(directory)
121
+ const skill = object(JSON.parse(readFileSync(join(directory, 'skill.json'), 'utf8')), 'skill.json')
122
+ if (skill.name !== context.skillName || skill.endpoint !== context.endpoint || skill.version !== context.skillVersion) {
123
+ throw new Error('Skill package documentation identity does not match the selected version')
124
+ }
125
+ const markdown = await lstat(join(directory, 'SKILL.md'))
126
+ if (!markdown.isFile() || markdown.size === 0) throw new Error('Skill package must contain its actual SKILL.md')
127
+ }
128
+
129
+ async function restoreInstallation(target, backup, placed, renamePath) {
130
+ if (placed) await rm(target, { recursive: true })
131
+ if (backup !== null) await renamePath(backup, target)
132
+ }
133
+
134
+ async function replaceInstallation(context, target, previous, dependencies) {
135
+ const renamePath = dependencies.rename === undefined ? rename : dependencies.rename
136
+ const suffix = randomUUID()
137
+ const stage = join(dirname(target), '.' + context.skillName + '.stage-' + suffix)
138
+ const backup = previous === null ? null : join(dirname(target), '.' + context.skillName + '.backup-' + suffix)
139
+ let moved = false, placed = false
140
+ await mkdir(stage, { mode: 0o700 })
141
+ try {
142
+ await verifySkillSource(context.skillDir, context)
143
+ for (const entry of await readdir(context.skillDir)) {
144
+ await cp(join(context.skillDir, entry), join(stage, entry), { recursive: true, force: false, errorOnExist: true })
145
+ }
146
+ await writeFile(join(stage, INSTALL_META), JSON.stringify({ source: context.runtimeCode, slug: context.skillName,
147
+ version: context.skillVersion, packageVersion: context.packageVersion, endpoint: context.endpoint,
148
+ installedAt: new Date().toISOString() }) + '\n', { flag: 'wx', mode: 0o600 })
149
+ await verifySkillSource(stage, context)
150
+ if (backup !== null) { await renamePath(target, backup); moved = true }
151
+ await renamePath(stage, target)
152
+ placed = true
153
+ return { path: target, status: previous === null ? 'installed' : 'updated',
154
+ previousVersion: previous === null ? null : previous.version, version: context.skillVersion,
155
+ documentationPath: join(target, 'SKILL.md'), backupPath: backup }
156
+ } catch (error) {
157
+ if (moved || placed) await restoreInstallation(target, moved ? backup : null, placed, renamePath)
158
+ throw error
159
+ } finally {
160
+ const remaining = await targetExists(stage)
161
+ if (remaining !== null) await rm(stage, { recursive: true })
162
+ }
163
+ }
164
+
165
+ export async function writeManagedSkill(context, target, options = {}) {
166
+ const allowCreate = options.allowCreate === true
167
+ const candidate = await managedTarget(context, target, allowCreate)
168
+ if (candidate.status === 'skipped') return candidate
169
+ assertNoManagedDowngrade(candidate.meta, context)
170
+ if (candidate.meta !== null && candidate.meta.version === context.skillVersion
171
+ && candidate.meta.packageVersion === context.packageVersion) {
172
+ await verifySkillSource(candidate.path, context)
173
+ return { path: candidate.path, status: 'current', version: context.skillVersion,
174
+ documentationPath: join(candidate.path, 'SKILL.md') }
175
+ }
176
+ await mkdir(dirname(candidate.path), { recursive: true })
177
+ await assertAccountAncestors(dirname(candidate.path))
178
+ const lock = join(dirname(candidate.path), '.' + context.skillName + '.install-lock')
179
+ try { await mkdir(lock, { mode: 0o700 }) } catch (error) {
180
+ if (error.code === 'EEXIST') throw new Error('Skill installation is already locked; inspect its pending update before retrying')
181
+ throw error
182
+ }
183
+ try {
184
+ const checked = await managedTarget(context, candidate.path, allowCreate)
185
+ if (checked.status === 'skipped') throw new Error('Skill installation ownership changed before update')
186
+ assertNoManagedDowngrade(checked.meta, context)
187
+ return await replaceInstallation(context, checked.path, checked.meta, options)
188
+ } finally { await rm(lock, { recursive: true }) }
189
+ }
190
+
191
+ export async function refreshManagedSkillCopies(context, dependencies = {}) {
192
+ const environment = dependencies.environment === undefined ? process.env : dependencies.environment
193
+ const home = dependencies.homeDirectory === undefined ? currentAccountHome() : dependencies.homeDirectory
194
+ const workingDirectory = dependencies.workingDirectory === undefined ? process.cwd() : dependencies.workingDirectory
195
+ if (!isAbsolute(home) || !isAbsolute(workingDirectory)) throw new Error('Account and project directories must be absolute')
196
+ const accountCodex = environment.CODEX_HOME === undefined ? join(home, '.codex') : environment.CODEX_HOME
197
+ if (typeof accountCodex !== 'string' || !isAbsolute(accountCodex) || !contained(home, accountCodex)) {
198
+ throw new Error('Automatic skill refresh requires CODEX_HOME inside the current account home')
199
+ }
200
+ const targets = [...new Set([join(workingDirectory, '.codex', 'skills', context.skillName),
201
+ join(accountCodex, 'skills', context.skillName)])]
202
+ const results = []
203
+ for (const target of targets) results.push(await writeManagedSkill(context, target, dependencies))
204
+ return results
205
+ }
package/installer.mjs CHANGED
@@ -1,10 +1,14 @@
1
+ import { latestOfficialSkillContext } from './official-skill-update.mjs'
2
+ import { installTarget, writeManagedSkill } from './installer-storage.mjs'
3
+ export { installTarget, readInstallMeta } from './installer-storage.mjs'
4
+ import { configureBrainClientCredential } from './broker-credentials.mjs'
1
5
  /**
2
6
  * 八个官方技能共用这一份安装器。packages/*-cli/installer.mjs 必须与本文件字节一致。
3
7
  * 禁止第二套超时、第二套版本来源、第二套 bin 名。
4
8
  */
5
- import { existsSync, readFileSync } from 'node:fs'
6
- import { cp, mkdir, rm, writeFile } from 'node:fs/promises'
7
- import { dirname, join, resolve } from 'node:path'
9
+ import { readFileSync } from 'node:fs'
10
+ import { writeFile } from 'node:fs/promises'
11
+ import { dirname, join } from 'node:path'
8
12
  import { stdin, stdout } from 'node:process'
9
13
  import { createInterface } from 'node:readline/promises'
10
14
  import { fileURLToPath } from 'node:url'
@@ -33,7 +37,6 @@ export {
33
37
 
34
38
  import { createBrokerTransport } from './broker-transport.mjs'
35
39
 
36
- const INSTALL_META = 'install-meta.json'
37
40
  const BROKER_STDIN_MAX_BYTES = 1_048_576
38
41
 
39
42
  function asObject(value, label) {
@@ -81,19 +84,6 @@ export function loadOfficialSkillContext(packageRoot) {
81
84
  }
82
85
  }
83
86
 
84
- export function readInstallMeta(target) {
85
- const path = join(target, INSTALL_META)
86
- if (!existsSync(path)) return null
87
- return asObject(JSON.parse(readFileSync(path, 'utf8')), INSTALL_META)
88
- }
89
-
90
- export function installTarget(skillName, explicit) {
91
- if (explicit) return resolve(explicit)
92
- const codexHome = process.env.CODEX_HOME?.trim()
93
- if (codexHome) return join(codexHome, 'skills', skillName)
94
- return join(process.cwd(), '.codex', 'skills', skillName)
95
- }
96
-
97
87
  export async function fetchLatestVersion(context) {
98
88
  const request = createBrokerTransport({ environment: process.env })
99
89
  const response = await request(context.latestEndpoint, { signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS) })
@@ -105,51 +95,38 @@ export async function fetchLatestVersion(context) {
105
95
  }
106
96
  }
107
97
 
108
- export async function installOfficialSkill(context, explicit) {
109
- const target = installTarget(context.skillName, explicit)
110
- await mkdir(target, { recursive: true })
111
- const previous = readInstallMeta(target)
112
- await rm(join(target, 'references'), { recursive: true, force: true })
113
- await cp(context.skillDir, target, { recursive: true, force: true })
114
- const installed = asObject(JSON.parse(readFileSync(join(target, 'skill.json'), 'utf8')), 'installed skill.json')
115
- const installedVersion = requiredString(installed.version, 'installed skill.json version')
116
- await writeFile(join(target, INSTALL_META), `${JSON.stringify({
117
- source: context.runtimeCode,
118
- slug: context.skillName,
119
- version: installedVersion,
120
- packageVersion: context.packageVersion,
121
- endpoint: context.endpoint,
122
- installedAt: new Date().toISOString(),
123
- }, null, 2)}\n`)
124
- if (previous?.version && previous.version !== installedVersion) {
125
- console.log(`${context.displayName} skill updated: ${target}`)
126
- console.log(` ${previous.version} → ${installedVersion}`)
127
- } else {
128
- console.log(`${context.displayName} skill installed: ${target} (${installedVersion})`)
129
- }
130
- console.log('Next: return to your IDE and state the goal. The agent reads the installed SKILL.md.')
98
+ function writeInstallationResult(runtime, value) {
99
+ const line = JSON.stringify(value) + '\n'
100
+ if (runtime.writeOutput === undefined) process.stdout.write(line)
101
+ else runtime.writeOutput(line)
131
102
  }
132
103
 
133
- export async function checkOfficialSkill(context, explicit) {
134
- const target = installTarget(context.skillName, explicit)
135
- const current = readInstallMeta(target)
136
- if (!current) {
137
- console.log(`${context.displayName} skill is not installed. Run: npx ${context.npmName}@latest install`)
138
- process.exitCode = 1
139
- return
140
- }
141
- const installedVersion = requiredString(current.version, 'install-meta.json version')
142
- const packageVersion = requiredString(current.packageVersion, 'install-meta.json packageVersion')
143
- console.log(`Installed: ${installedVersion} (package ${packageVersion})`)
144
- const latest = await fetchLatestVersion(context)
145
- console.log(`Latest on cli.tax: ${latest.version}`)
146
- if (installedVersion === latest.version) {
147
- console.log('Up to date.')
148
- return
149
- }
150
- console.log(`Update available: ${installedVersion} → ${latest.version}`)
151
- console.log(`Run: npx ${context.npmName}@latest install`)
152
- process.exitCode = 1
104
+ function installationDependencies(dependencies) {
105
+ return dependencies === undefined ? brokerDependencies() : dependencies
106
+ }
107
+
108
+ export async function installOfficialSkill(context, explicit, dependencies) {
109
+ const runtime = installationDependencies(dependencies)
110
+ const selected = await latestOfficialSkillContext(context, runtime)
111
+ const environment = runtime.environment === undefined ? process.env : runtime.environment
112
+ const workingDirectory = runtime.workingDirectory === undefined ? process.cwd() : runtime.workingDirectory
113
+ const target = installTarget(selected.skillName, explicit, environment, workingDirectory)
114
+ const installed = await writeManagedSkill(selected, target, { ...runtime, allowCreate: true })
115
+ if (installed.status === 'skipped') throw new Error('Skill install refused: ' + installed.reason)
116
+ writeInstallationResult(runtime, { installed, reloadRequired: installed.status !== 'current' })
117
+ return installed
118
+ }
119
+
120
+ export async function checkOfficialSkill(context, explicit, dependencies) {
121
+ const runtime = installationDependencies(dependencies)
122
+ const selected = await latestOfficialSkillContext(context, runtime)
123
+ const environment = runtime.environment === undefined ? process.env : runtime.environment
124
+ const workingDirectory = runtime.workingDirectory === undefined ? process.cwd() : runtime.workingDirectory
125
+ const target = installTarget(selected.skillName, explicit, environment, workingDirectory)
126
+ const installed = await writeManagedSkill(selected, target, runtime)
127
+ if (installed.status === 'skipped') throw new Error('Skill check could not update its managed target: ' + installed.reason)
128
+ writeInstallationResult(runtime, { installed, reloadRequired: installed.status === 'updated' })
129
+ return installed
153
130
  }
154
131
 
155
132
  export function defaultUsage(context, extraLines) {
@@ -157,10 +134,12 @@ export function defaultUsage(context, extraLines) {
157
134
  `${context.npmName} — install and run the ${context.displayName} skill from CLI.Tax`,
158
135
  '',
159
136
  'Usage:',
137
+ ' configure < credential.json',
138
+ ' Store the Brain Client credential for this account; never put tokens in command arguments.',
160
139
  ` npx ${context.npmName}@latest install [directory]`,
161
140
  ` Install the ${context.displayName} skill for the current IDE.`,
162
141
  ` npx ${context.npmName}@latest check [directory]`,
163
- ' Check whether the installed skill has a newer version.',
142
+ ' Check and atomically update an already managed skill to the current official release.',
164
143
  ` npx ${context.npmName}@latest run`,
165
144
  " Run this skill's applicability or onboarding flow; only a real HTTP invocation can trigger automatic evaluation.",
166
145
  ` npx ${context.npmName}@latest invoke <operation> <JSON-object>`,
@@ -169,7 +148,8 @@ export function defaultUsage(context, extraLines) {
169
148
  ' Read one {"operation":"...","input":{...}} request from JSON stdin.',
170
149
  ` npx ${context.npmName}@latest recover <operation> <requestId>`,
171
150
  ' Query an uncertain invocation without resending or charging again.',
172
- 'Credential: CLITAX_BRAIN_CLIENT_TOKEN_FILE (the broker reads it; never pass the token).',
151
+ 'Credential: configure reads a token-file JSON document from stdin and stores it once for the current account.',
152
+ 'An explicit CLITAX_BRAIN_CLIENT_TOKEN_FILE must be absolute. Revoked keys require a fresh authenticated copy from CLI.Tax.',
173
153
  `Endpoint: ${context.endpoint}`,
174
154
  ]
175
155
  if (extraLines?.length) lines.push('', ...extraLines)
@@ -270,7 +250,12 @@ export async function dispatchOfficialSkillCli(options) {
270
250
  const command = args[0] ?? 'help'
271
251
  const argument = args[1]
272
252
  try {
273
- if (command === 'install') await installOfficialSkill(context, argument)
253
+ if (command === 'configure') {
254
+ if (args.length !== 1) throw new Error('configure accepts credentials only through JSON stdin')
255
+ const configured = await configureBrainClientCredential(await readBrokerSource(stdin))
256
+ process.stdout.write(JSON.stringify(configured) + '\n')
257
+ }
258
+ else if (command === 'install') await installOfficialSkill(context, argument)
274
259
  else if (command === 'check') await checkOfficialSkill(context, argument)
275
260
  else if (command === 'run') await options.runCommand(context)
276
261
  else if (command === 'recover') await runBrokerRecovery(context, args)
@@ -0,0 +1,151 @@
1
+ import { refreshManagedSkillCopies } from './installer-storage.mjs'
2
+ import { execFile, spawn } from 'node:child_process'
3
+ import { promisify } from 'node:util'
4
+ import { lstat, mkdtemp, readFile, rename, rm } from 'node:fs/promises'
5
+ import { join, win32 } from 'node:path'
6
+ import { assertAccountAncestors, currentAccountHome, ensureAccountDirectory } from './broker-account-storage.mjs'
7
+ import { pathToFileURL } from 'node:url'
8
+ import { accountBrokerDirectory } from './broker-credentials.mjs'
9
+ import { createBrokerTransport } from './broker-transport.mjs'
10
+
11
+ const runFile = promisify(execFile)
12
+ export const LOOKUP_TIMEOUT_MS = 8000
13
+ const INSTALL_TIMEOUT_MS = 120_000
14
+ const RELEASE_PATTERN = /^(?:v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/
15
+ const OFFICIAL_IDENTITIES = Object.freeze({
16
+ 'cli-aimlock': 'R3mQ8kWpXn', 'cli-blueprint': 'wvz6zmRWmX', 'cli-calctool': 'KKyA6xljUX',
17
+ 'cli-swarm': 'zj7fTPVh4p', 'cli-validator': 'Xx9ZkQmW3p', 'cli-confirm-protocol': 'Cf8Pr7Tm2Q',
18
+ 'cli-archguard': 'Ag4Ch8Rd2K', 'cli-mergeguard': 'Mm7GnPqR2v',
19
+ })
20
+
21
+ function releaseVersion(value) {
22
+ if (typeof value !== 'string' || !RELEASE_PATTERN.test(value)) throw new Error('Official release version must be X.Y.Z')
23
+ return value.replace(/^v/, '')
24
+ }
25
+
26
+ export async function inspectOfficialRelease(context, dependencies = {}) {
27
+ if (OFFICIAL_IDENTITIES[context.npmName] !== context.runtimeCode || typeof context.packageRoot !== 'string'
28
+ || context.endpoint !== 'https://cli.tax/' + context.runtimeCode) {
29
+ throw new Error('Official package identity is required for the update check')
30
+ }
31
+ const environment = dependencies.environment === undefined ? process.env : dependencies.environment
32
+ const request = dependencies.request === undefined ? createBrokerTransport({ environment }) : dependencies.request
33
+ const endpoint = 'https://cli.tax/api/public/skills/' + context.runtimeCode
34
+ if (!/^[A-Za-z0-9]{10}$/.test(context.runtimeCode)) throw new Error('Official runtime code is invalid')
35
+ const response = await request(endpoint, { redirect: 'error', signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS) })
36
+ if (!response.ok) throw new Error('Official release lookup failed: HTTP ' + response.status)
37
+ const payload = await response.json()
38
+ const version = releaseVersion(payload.version)
39
+ const local = releaseVersion(context.packageVersion)
40
+ const left = version.split('.').map(Number), right = local.split('.').map(Number)
41
+ const differing = left.findIndex((value, index) => value !== right[index])
42
+ if (differing !== -1 && left[differing] < right[differing]) {
43
+ throw new Error('The local development package is newer than the published release; refusing to downgrade or overwrite workspace sources')
44
+ }
45
+ return { version, current: local === version }
46
+ }
47
+
48
+ async function validateCachedPackage(directory, context, version) {
49
+ const packageRoot = join(directory, 'node_modules', context.npmName)
50
+ await assertAccountAncestors(packageRoot)
51
+ const status = await lstat(packageRoot)
52
+ if (!status.isDirectory() || status.isSymbolicLink()) throw new Error('Cached official package must be a regular directory')
53
+ const manifest = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'))
54
+ const skill = JSON.parse(await readFile(join(packageRoot, 'skill', 'skill.json'), 'utf8'))
55
+ if (manifest.name !== context.npmName || manifest.version !== version || releaseVersion(skill.version) !== version
56
+ || skill.name !== context.skillName || skill.endpoint !== context.endpoint || typeof skill.schemaVersion !== 'string') {
57
+ throw new Error('Installed official package identity does not match the published release')
58
+ }
59
+ return { ...context, packageRoot, packageVersion: version, skillVersion: skill.version,
60
+ schemaVersion: skill.schemaVersion, skillDir: join(packageRoot, 'skill') }
61
+ }
62
+
63
+ export function windowsNpmEntry(output) {
64
+ const commands = output.split(/\r?\n/).map(line => line.trim()).filter(line => /\\npm\.cmd$/i.test(line))
65
+ if (!commands.length || !win32.isAbsolute(commands[0]) || /[\u0000-\u001f]/.test(commands[0])) {
66
+ throw new Error('where.exe did not return an absolute npm.cmd location')
67
+ }
68
+ return { command: commands[0], script: win32.join(win32.dirname(commands[0]), 'node_modules', 'npm', 'bin', 'npm-cli.js') }
69
+ }
70
+
71
+ async function npmInvocation(environment) {
72
+ if (process.platform !== 'win32') return { executable: 'npm', args: [] }
73
+ const found = await runFile('where.exe', ['npm'], { env: environment, windowsHide: true, timeout: LOOKUP_TIMEOUT_MS })
74
+ const entry = windowsNpmEntry(found.stdout)
75
+ for (const path of [entry.command, entry.script]) {
76
+ await assertAccountAncestors(win32.dirname(path), 'win32')
77
+ const status = await lstat(path)
78
+ if (!status.isFile() || status.isSymbolicLink()) throw new Error('Windows npm entry must be a regular file without symlink ancestors')
79
+ }
80
+ return { executable: process.execPath, args: [entry.script] }
81
+ }
82
+
83
+ async function installPackage(directory, context, version, environment) {
84
+ if (typeof environment.PATH !== 'string' || !environment.PATH) throw new Error('Package installation PATH is required')
85
+ const childEnvironment = { PATH: environment.PATH, HOME: currentAccountHome() }
86
+ for (const name of ['HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', 'NODE_EXTRA_CA_CERTS', 'SystemRoot', 'COMSPEC', 'PATHEXT']) {
87
+ if (environment[name] !== undefined) childEnvironment[name] = environment[name]
88
+ }
89
+ const invocation = await npmInvocation(childEnvironment)
90
+ await new Promise((accept, reject) => {
91
+ const child = spawn(invocation.executable, [...invocation.args, 'install', '--prefix', directory,
92
+ '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', '--save-exact',
93
+ '--registry=https://registry.npmjs.org', context.npmName + '@' + version],
94
+ { env: childEnvironment, stdio: 'ignore', timeout: INSTALL_TIMEOUT_MS })
95
+ child.once('error', reject)
96
+ child.once('exit', (code, signal) => code === 0 ? accept()
97
+ : reject(new Error('Official package update failed: exit=' + code + ' signal=' + signal)))
98
+ })
99
+ }
100
+
101
+ export async function latestOfficialSkillContext(context, dependencies = {}) {
102
+ const release = await inspectOfficialRelease(context, dependencies)
103
+ if (release.current) return context
104
+ const environment = dependencies.environment === undefined ? process.env : dependencies.environment
105
+ const home = dependencies.homeDirectory === undefined ? currentAccountHome() : dependencies.homeDirectory
106
+ const directory = join(accountBrokerDirectory(environment, process.platform, home), 'packages')
107
+ await ensureAccountDirectory(directory, dependencies)
108
+ const target = join(directory, context.npmName + '-' + release.version)
109
+ try {
110
+ await lstat(target)
111
+ return await validateCachedPackage(target, context, release.version)
112
+ } catch (error) { if (error.code !== 'ENOENT') throw error }
113
+ const staged = await mkdtemp(join(directory, '.update-'))
114
+ try {
115
+ const install = dependencies.installPackage === undefined ? installPackage : dependencies.installPackage
116
+ await install(staged, context, release.version, environment)
117
+ await validateCachedPackage(staged, context, release.version)
118
+ try { await rename(staged, target) } catch (error) {
119
+ if (!['EEXIST', 'ENOTEMPTY'].includes(error.code)) throw error
120
+ await validateCachedPackage(target, context, release.version)
121
+ }
122
+ return await validateCachedPackage(target, context, release.version)
123
+ } finally { await rm(staged, { recursive: true, force: true }) }
124
+ }
125
+
126
+ export async function latestOfficialModule(context, filename, dependencies) {
127
+ const updated = await latestOfficialSkillContext(context, dependencies)
128
+ if (updated.packageRoot === context.packageRoot) return null
129
+ return { context: updated, module: await import(pathToFileURL(join(updated.packageRoot, filename)).href) }
130
+ }
131
+
132
+ export function withUpgradeMetadata(result, upgrade) {
133
+ if (result.upgrade === undefined) return { ...result, upgrade }
134
+ return { ...result, upgrade: { ...result.upgrade, previousVersion: upgrade.previousVersion,
135
+ runtimeUpdated: upgrade.runtimeUpdated || result.upgrade.runtimeUpdated,
136
+ reloadRequired: upgrade.reloadRequired || result.upgrade.reloadRequired,
137
+ managedCopies: [...upgrade.managedCopies, ...result.upgrade.managedCopies] } }
138
+ }
139
+
140
+ export async function prepareOfficialSkillUse(context, filename, dependencies) {
141
+ const selected = await latestOfficialSkillContext(context, dependencies)
142
+ const managedCopies = await refreshManagedSkillCopies(selected, dependencies)
143
+ const runtimeUpdated = selected.packageRoot !== context.packageRoot
144
+ const changedCopies = managedCopies.filter(item => item.status === 'updated')
145
+ const upgrade = { previousVersion: context.packageVersion, version: selected.packageVersion, runtimeUpdated,
146
+ reloadRequired: runtimeUpdated || changedCopies.length > 0, managedCopies,
147
+ documentationPaths: changedCopies.map(item => item.documentationPath) }
148
+ if (runtimeUpdated) upgrade.documentationPaths.push(join(selected.skillDir, 'SKILL.md'))
149
+ return { context: selected, upgrade,
150
+ module: runtimeUpdated ? await import(pathToFileURL(join(selected.packageRoot, filename)).href) : null }
151
+ }
package/package.json CHANGED
@@ -13,6 +13,10 @@
13
13
  "files": [
14
14
  "cli.mjs",
15
15
  "installer.mjs",
16
+ "installer-storage.mjs",
17
+ "broker-account-storage.mjs",
18
+ "broker-credentials.mjs",
19
+ "official-skill-update.mjs",
16
20
  "broker.mjs",
17
21
  "broker-failures.mjs",
18
22
  "broker-recovery.mjs",
@@ -38,5 +42,5 @@
38
42
  "url": "https://github.com/88208555/Validator-clitax.git"
39
43
  },
40
44
  "type": "module",
41
- "version": "7.0.38"
45
+ "version": "7.0.39"
42
46
  }
package/skill/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: '交付前质量门禁:三道防线(静态/动态/对抗)递
5
5
 
6
6
  # Validator
7
7
 
8
- Package version: v7.0.38
8
+ Package version: v7.0.39
9
9
 
10
10
  Validator 是技能链最后一站,只消费冻结目标和真实执行证据;模型解释没有裁判权。
11
11
 
@@ -98,7 +98,7 @@ GoldenBaseline 只有 `frozen: true` 才有效。来源只允许 `repository-com
98
98
  ## 受限调用与自动评价闭环
99
99
 
100
100
  - IDE / 智能体必须通过本包 `invoke` 或 JSON-stdin `broker` 调用,不得直接拼装技能 HTTP 请求,也不得读取 BrainClient token。
101
- - broker `CLITAX_BRAIN_CLIENT_TOKEN_FILE` 读取身份;macOS/Linux 文件必须为当前 broker 账户所有且权限 `0600`,Windows 文件必须位于受限 `%LOCALAPPDATA%\CLI.Tax\broker` 目录。
101
+ - broker 默认读取账号共享凭据文件;显式 `CLITAX_BRAIN_CLIENT_TOKEN_FILE` 使用绝对路径覆盖;macOS/Linux 文件必须为当前 broker 账户所有且权限 `0600`,Windows 文件必须位于受限 `%LOCALAPPDATA%\CLI.Tax\broker` 目录。
102
102
  - broker 只需要 Brain Client HTTPS、受限身份文件和调用方显式传入的路径,本身不需要完整磁盘访问。若要保证 IDE 无法读取身份文件,必须把 broker 放进独立低权限系统账户或沙箱服务,并只暴露受限 IPC;broker 与 IDE 同账户运行时,`0600` 不能隔离二者,禁止声称令牌已隔离。
103
103
  - broker 只用 `Authorization: BrainClient …` 发起一次 runtime 请求。HTTP 成功后必须保留响应顶层原始 `feedbackReceiptId`、`feedbackInvocationId` 和 `feedbackEvaluation.digest`,不得生成、猜测、复用或跨调用转移。
104
104
  - Brain Client 服务端必须严格绑定请求/响应的 `requestId` 和 `schemaVersion`,再根据真实状态、验证结果、服务端耗时与 findings 生成并持久化权威评分、评语和摘要。broker 不得生成分数或评语。
@@ -125,3 +125,32 @@ GoldenBaseline 只有 `frozen: true` 才有效。来源只允许 `repository-com
125
125
  仅在 TLS 握手前确定尚未发送 HTTP 请求时,broker 才允许最多 3 次连接尝试,并受总超时约束。请求发出后发生断线或响应中断,只用 GET 查询原 requestId 的服务端回执,禁止重发 POST;未取得有效回执时保留不确定状态,不得假定成功或继续依赖步骤。
126
126
 
127
127
  `npx cli-validator@latest recover <operation> <requestId>` 可重新查询原调用,不会重做操作或重复计费。链恢复不会跳过人工确认,也不会自动重跑结果不确定的本地命令。代理连接需 Node.js 22.21+ 或 24.5+;不支持的运行时会明确报错。
128
+
129
+ ## 执行完整性共同规则
130
+
131
+ 1. 工程目标、已接受范围和验收项必须持久化;新增需求先路由与合并,不能覆盖原目标。子任务有明确服务目标的理由,执行仅用本链已匹配技能。每次恢复读取 task-resume,核对剩余项、pending请求和continuationNotifications。
132
+ 2. 默认由主代理完成工作,禁止为了省事创建子代理、把简单查找/改名/少量修改/单条命令/例行检查/汇总交接给多智能体,禁止为达到门槛拆分或夸大任务。启用Aimlock或Swarm模式不是创建授权,管理/运维/安全/协调是主代理职责,不额外创建常驻智能体。只有业务确需独立且实质性的交付、主代理同时有可推进的独立工作、预期收益严格高于上下文传递/协调/验收成本时才派单;复用已有合适负责人,用户禁止委派时不得创建。每次创建前记录业务理由、交付物、验收项、主代理工作、成本收益、精确路径和原负责人;只创建当前需要的最少数量,不预建空闲角色,不递归扩编或重复扫描。规模门槛200行/3文件/跨模块仅为必要条件,不能单独证明值得委派。主代理负责整合和完整验收,不把半成品当完成;预算抱怨不是停止指令。
133
+ 3. 自报、回复送达和动作完成不等于工程交付验证。reported始终待验收;Swarm接受工程任务时复用Validator校验签名、有效期、计划/产物/任务绑定。无证据、伪造runner或失败检查不得成为绿色完成。
134
+ 4. 原任务交接前保存检查点并释放旧锁;回程只发持久通知,宿主消费后重新核验基线、快照与写入权限。历史恢复结果不是新授权。技能不能自行唤醒未接入的IDE。
135
+ 5. 心跳停止仅允许自动回收尚未开工的assigned任务;claimed/running进入执行结果待核对状态,禁止盲目重复执行。已回传、已验收、失败和取消任务不会被自动重派。服务器停滞回收同时保存会员通知,对话界面定期读取展示。
136
+ 6. 预计长任务在预算初始化后、深读前提出一次精确自动续时策略;只有真实授权才自动续时。时间、文件数、token和写入权限分别计量;额度/次数耗尽、撤销和完成必须明确停止并说明下一步。读取预算不是付费充值,计时器由宿主运行。
137
+ 7. 云端沙箱开关按调用会员读取;关闭时仅允许当前受审官方源码摘要在受控worker中直接执行,并记录executionIsolation。未知或修改过的源码明确要求sandbox,不伪造隔离结果;worker直接执行不是OS沙箱。
138
+ 8. 使用技能前检查官方发布版本并自动升级可管理的安装副本与客户端;配置失败或升级失败停止并报告。通过configure的JSON标准输入导入一次账号凭据,后续项目/分支/任务共享;密钥不进入源码、URL或命令参数。显式环境覆盖必须是绝对路径。网页复制在点击时获取当前凭据,页面仅展示无密钥地址;已撤销密钥没有权限自动获取新密钥,需已认证网页重新同步一次。
139
+
140
+ English: keep simple work with the main agent. Delegate only substantial independent business deliverables when benefits exceed coordination costs; use the fewest needed agents, never create idle management agents. Enabling Aimlock does not authorize delegation. Finish all authorized work; a cost complaint is not a stop request. Preserve the original goal; verify execution evidence; resume from durable checkpoints; never replay uncertain side effects. Read-time renewal requires bounded approval. Reuse account credentials and check managed skill updates before use; revoked credentials require an authenticated refresh.
141
+
142
+ Русский: сохраняйте исходную цель, проверяйте доказательства выполнения и возобновляйте работу из сохранённой точки. Не повторяйте операции с неизвестным результатом. Продление чтения требует ограниченного разрешения; ключи учётной записи используются повторно, обновления навыков проверяются перед вызовом.
143
+
144
+ ## 账号共享凭据与自动更新
145
+
146
+ 在已登录的能力市场复制安装入口,将内容粘贴给 IDE。页面只展示原地址,剪贴板会携带当前账号凭据。IDE 将四字段凭据 JSON 经标准输入交给 `npx cli-aimlock@latest configure`;不要放到命令参数、项目文件或日志中。一次配置供同一操作系统账号的所有项目、分支和任务使用,八个技能共享同一文件。
147
+
148
+ 默认位置:macOS 为 `~/Library/Application Support/CLI.Tax/broker/credential.json`,Linux 为 `~/.local/share/CLI.Tax/broker/credential.json`,Windows 为 `%LOCALAPPDATA%\CLI.Tax\broker\credential.json`。显式 `CLITAX_BRAIN_CLIENT_TOKEN_FILE` 仍按绝对路径覆盖默认位置;迁移旧 IDE 配置时移除其过时覆盖,再使用账号共享文件。macOS/Linux 校验当前账号所有权和0600权限;Windows校验仅当前账号与SYSTEM可访问的ACL。
149
+
150
+ 每次新技能调用先查询官方发布版本,精确版本下载并校验身份后自动使用;更新已托管的当前项目与账号技能目录,失败恢复旧目录,禁止覆盖 Git 跟踪源码或未托管内容。升级返回 `upgrade.reloadRequired` 和说明路径时,IDE 应读取更新后的 SKILL.md、核对本任务合同再继续。install/check同样自动更新,不需要每次人工发升级指令。查询不确定调用的原回执不升级、不重发操作。
151
+
152
+ 升级不会清除账号凭据;各调用重新读取共享文件,因此重新同步一次密钥后所有任务使用新值。已撤销或失效的密钥不能为自己取得新权限,必须从已认证网页重新同步一次。两个不同操作系统账号不共享私密文件。
153
+
154
+ English: configure once using JSON stdin; all tasks under the same OS account reuse the credential. Each new invocation checks and updates the official package and managed documentation. Reload updated instructions when indicated. Revoked keys require a fresh authenticated copy.
155
+
156
+ Русский: настройте ключ один раз через JSON stdin для всех задач пользователя ОС. Перед новым вызовом пакет и управляемые инструкции обновляются автоматически. Отозванный ключ требует повторной синхронизации с авторизованной страницы.
package/skill/skill.json CHANGED
@@ -5,6 +5,6 @@
5
5
  "method": "POST",
6
6
  "name": "validator",
7
7
  "type": "Skill",
8
- "version": "v7.0.38",
8
+ "version": "v7.0.39",
9
9
  "schemaVersion": "validator.skill.request/1.0"
10
10
  }
@@ -6,7 +6,7 @@ const REQ = "validator.skill.request/1.0";
6
6
  const RES = "validator.skill.response/1.0";
7
7
  const ERR = "validator.skill.error/1.0";
8
8
  const NAME = "validator";
9
- const COMPILER_VERSION = "v7.0.38";
9
+ const COMPILER_VERSION = "v7.0.39";
10
10
  const CATALOG_SCHEMA = "cli.tax.skill-catalog/1.0";
11
11
  const RECEIPT_SCHEMA = "validator.execution-receipt/1.0";
12
12
  const VALIDATION_SUBJECT_SCHEMA = "validator.validation-subject/1.0";
@@ -177,7 +177,7 @@ function validateGoldenBaseline(value, entityRef, tests) {
177
177
  if (!shaRegex.test(text(value.testsSha256)) || value.testsSha256 !== validatorReceiptSubject(tests)) findings.push(finding("P0", "GOLDEN-BASELINE-TESTS", `${entityRef}.testsSha256`, "Frozen tests digest does not match subject tests"));
178
178
  return findings;
179
179
  }
180
- function readValidationSubject(value, entityRef) {
180
+ export function readValidationSubject(value, entityRef) {
181
181
  if (!isObj(value)) return { findings: [finding("P0", "VALIDATION-SUBJECT-REQUIRED", entityRef, "A validation subject is required")] };
182
182
  const findings = [];
183
183
  const allowed = new Set(["schemaVersion", "memberId", "chainId", "executedAt", "files", "artifactSha256", "validationRunId", "planId", "tests", "policy", "goldenBaseline", "contracts"]);
@@ -260,7 +260,7 @@ function createTestEvidence(receipt, subject, subjectDigest, index) {
260
260
  durationMs: receipt.result.durationMs, summary: receipt.result.summary, artifactSha256: subject.artifactSha256,
261
261
  subject, subjectDigest, receipt };
262
262
  }
263
- function evidenceState(evidence, subject, subjectDigest) {
263
+ export function evidenceState(evidence, subject, subjectDigest) {
264
264
  if (!isObj(evidence) || evidence.schemaVersion !== TEST_EVIDENCE_SCHEMA || evidence.runner === "local") return "unverifiable";
265
265
  let evidenceDigest;
266
266
  try { evidenceDigest = validatorReceiptSubject(evidence.subject); } catch { return "unverifiable"; }