cli-aimlock 7.0.37 → 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 +14 -0
- package/aimlock-chain-calls.mjs +10 -2
- package/aimlock-chain-executor.mjs +4 -5
- package/aimlock-coordination.mjs +4 -3
- package/aimlock-runtime.mjs +1 -1
- package/aimlock-tasks-cli.mjs +202 -0
- package/brain-client.mjs +13 -1
- package/broker-account-storage.mjs +135 -0
- package/broker-credentials.mjs +125 -0
- package/broker.mjs +13 -93
- package/cli.mjs +4 -1
- package/installer-storage.mjs +205 -0
- package/installer.mjs +48 -63
- package/official-skill-update.mjs +151 -0
- package/package.json +11 -4
- package/skill/SKILL.md +45 -6
- package/skill/references/task-routing.md +13 -0
- package/skill/skill.json +1 -1
package/README.md
CHANGED
|
@@ -71,3 +71,17 @@ Brain Client 服务端在同一次 runtime 请求的事务中绑定真实响应
|
|
|
71
71
|
仅在 TLS 握手前确定尚未发送 HTTP 请求时,broker 才允许最多 3 次连接尝试,并受总超时约束。请求发出后发生断线或响应中断,只用 GET 查询原 requestId 的服务端回执,禁止重发 POST;未取得有效回执时保留不确定状态,不得假定成功或继续依赖步骤。
|
|
72
72
|
|
|
73
73
|
`npx cli-aimlock@latest recover <operation> <requestId>` 可重新查询原调用,不会重做操作或重复计费。链恢复不会跳过人工确认,也不会自动重跑结果不确定的本地命令。代理连接需 Node.js 22.21+ 或 24.5+;不支持的运行时会明确报错。
|
|
74
|
+
|
|
75
|
+
## 账号共享凭据与自动更新
|
|
76
|
+
|
|
77
|
+
在已登录的能力市场复制安装入口,将内容粘贴给 IDE。页面只展示原地址,剪贴板会携带当前账号凭据。IDE 将四字段凭据 JSON 经标准输入交给 `npx cli-aimlock@latest configure`;不要放到命令参数、项目文件或日志中。一次配置供同一操作系统账号的所有项目、分支和任务使用,八个技能共享同一文件。
|
|
78
|
+
|
|
79
|
+
默认位置: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。
|
|
80
|
+
|
|
81
|
+
每次新技能调用先查询官方发布版本,精确版本下载并校验身份后自动使用;更新已托管的当前项目与账号技能目录,失败恢复旧目录,禁止覆盖 Git 跟踪源码或未托管内容。升级返回 `upgrade.reloadRequired` 和说明路径时,IDE 应读取更新后的 SKILL.md、核对本任务合同再继续。install/check同样自动更新,不需要每次人工发升级指令。查询不确定调用的原回执不升级、不重发操作。
|
|
82
|
+
|
|
83
|
+
升级不会清除账号凭据;各调用重新读取共享文件,因此重新同步一次密钥后所有任务使用新值。已撤销或失效的密钥不能为自己取得新权限,必须从已认证网页重新同步一次。两个不同操作系统账号不共享私密文件。
|
|
84
|
+
|
|
85
|
+
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.
|
|
86
|
+
|
|
87
|
+
Русский: настройте ключ один раз через JSON stdin для всех задач пользователя ОС. Перед новым вызовом пакет и управляемые инструкции обновляются автоматически. Отозванный ключ требует повторной синхронизации с авторизованной страницы.
|
package/aimlock-chain-calls.mjs
CHANGED
|
@@ -30,7 +30,7 @@ function skillContext(state, skillId) {
|
|
|
30
30
|
|
|
31
31
|
async function startCall(session, kind, operation, input) {
|
|
32
32
|
const call = { callId: 'call-' + randomUUID(), kind, operation, inputDigest: sha256(JSON.stringify(input)),
|
|
33
|
-
requestId: null, status: 'started', pid: null, receipt: null, error: null,
|
|
33
|
+
requestId: null, requestSchemaVersion: null, status: 'started', pid: null, receipt: null, error: null,
|
|
34
34
|
startedAt: new Date().toISOString(), completedAt: null }
|
|
35
35
|
session.record.calls.push(call)
|
|
36
36
|
await saveExecution(session.file, session.state)
|
|
@@ -51,10 +51,12 @@ export async function callSkill(session, skillId, operation, input) {
|
|
|
51
51
|
const dependencies = session.dependencies
|
|
52
52
|
const invocation = await invokeOfficialSkill(context, operation, input, {
|
|
53
53
|
environment: dependencies.environment, credentialAccess: dependencies.credentialAccess,
|
|
54
|
+
workingDirectory: session.root, homeDirectory: dependencies.homeDirectory,
|
|
54
55
|
request: async (url, options) => {
|
|
55
56
|
if (options.method === 'POST') {
|
|
56
57
|
const request = JSON.parse(options.body).input
|
|
57
58
|
call.requestId = request.requestId
|
|
59
|
+
call.requestSchemaVersion = request.schemaVersion
|
|
58
60
|
call.status = 'dispatched'
|
|
59
61
|
await saveExecution(session.file, session.state)
|
|
60
62
|
}
|
|
@@ -113,7 +115,13 @@ export async function recoverSkillCall(session, skillId) {
|
|
|
113
115
|
}
|
|
114
116
|
call.status = 'uncertain'
|
|
115
117
|
await saveExecution(session.file, session.state)
|
|
116
|
-
const
|
|
118
|
+
const context = skillContext(session.state, skillId)
|
|
119
|
+
// Legacy calls used the initialized context; new calls persist the schema actually sent before POST.
|
|
120
|
+
const schemaVersion = Object.hasOwn(call, 'requestSchemaVersion') ? call.requestSchemaVersion : context.schemaVersion
|
|
121
|
+
if (typeof schemaVersion !== 'string' || !/^[a-z-]+\.skill\.request\/\d+\.\d+$/.test(schemaVersion)) {
|
|
122
|
+
fail('AIMLOCK_CHAIN_RECOVERY_INVALID', 'Dispatched request schema is missing or invalid')
|
|
123
|
+
}
|
|
124
|
+
const invocation = await recoverOfficialSkill({ ...context, schemaVersion },
|
|
117
125
|
call.operation, call.requestId, session.dependencies)
|
|
118
126
|
call.receipt = invocation
|
|
119
127
|
call.status = 'recorded'
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decisionResumesTask, decisionTargetsTask } from 'cli-swarm/coordinator'
|
|
1
2
|
import { randomUUID } from 'node:crypto'
|
|
2
3
|
import { assertChainNotSuspended } from './aimlock-coordination.mjs'
|
|
3
4
|
import { fail } from './aimlock-local-fs.mjs'
|
|
@@ -57,14 +58,13 @@ function finish(record, output, status) {
|
|
|
57
58
|
|
|
58
59
|
async function stillWaiting(session, output, pending) {
|
|
59
60
|
if (output.status === 'waiting') return true
|
|
60
|
-
const releasedByHuman = pending.decision
|
|
61
|
-
&& pending.decision.answer === 'resume:' + pending.input.agentId
|
|
61
|
+
const releasedByHuman = pending.decision && decisionResumesTask(pending.decision, pending.input)
|
|
62
62
|
if (output.status !== 'blocked' || (output.wakePackage?.reason !== 'event-received' && !releasedByHuman)) return false
|
|
63
63
|
const current = (await callCoordinator(session, 'status', {})).state
|
|
64
64
|
const task = current.tasks.find((item) => item.taskId === pending.input.taskId)
|
|
65
65
|
if (!task || ['completed', 'failed', 'reclaimed'].includes(task.status)) return false
|
|
66
66
|
return current.waits.some((wait) => wait.taskId === task.taskId && wait.status === 'active')
|
|
67
|
-
|| current.decisions.some((decision) => decision.status === 'pending'
|
|
67
|
+
|| current.decisions.some((decision) => decisionTargetsTask(decision, task) && decision.status === 'pending')
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
async function driveWait(session) {
|
|
@@ -80,8 +80,7 @@ async function driveWait(session) {
|
|
|
80
80
|
await prepareHuman(session, output.confirmProtocolRequests[0].input.interaction, pending.input)
|
|
81
81
|
return
|
|
82
82
|
}
|
|
83
|
-
const releasedByHuman = pending.decision
|
|
84
|
-
&& pending.decision.answer === 'resume:' + pending.input.agentId
|
|
83
|
+
const releasedByHuman = pending.decision && decisionResumesTask(pending.decision, pending.input)
|
|
85
84
|
&& output.status === 'resolved'
|
|
86
85
|
if (releasedByHuman) {
|
|
87
86
|
finish(session.record, { ...output, status: 'resolved-by-human',
|
package/aimlock-coordination.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decisionResumesTask, decisionTargetsTask } from 'cli-swarm/coordinator'
|
|
1
2
|
import { verify } from 'node:crypto'
|
|
2
3
|
import { lstat, readFile } from 'node:fs/promises'
|
|
3
4
|
import { basename, resolve } from 'node:path'
|
|
@@ -35,9 +36,9 @@ function failureHandled(task, tasks, inspected = new Set()) {
|
|
|
35
36
|
|
|
36
37
|
function parkedAfterDecision(task, tasks, decisions) {
|
|
37
38
|
if (task.status !== 'blocked' || task.blockedReason !== 'human-decision') return false
|
|
38
|
-
const decision = decisions.findLast((item) => item
|
|
39
|
+
const decision = decisions.findLast((item) => decisionTargetsTask(item, task))
|
|
39
40
|
return decision?.status === 'resolved'
|
|
40
|
-
&& tasks.some((active) => active.status === 'active' && decision
|
|
41
|
+
&& tasks.some((active) => active.status === 'active' && decisionResumesTask(decision, active))
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
function assertChainRunnable(state, chainId) {
|
|
@@ -46,7 +47,7 @@ function assertChainRunnable(state, chainId) {
|
|
|
46
47
|
const wait = state.waits.find((item) => item.chainId === chainId && item.status === 'active')
|
|
47
48
|
if (wait) fail('AIMLOCK_COORDINATION_WAITING', 'chain ' + chainId + ' is suspended until ' + wait.event + ' or ' + wait.deadlineAt)
|
|
48
49
|
const pending = state.decisions.some((decision) => decision.status === 'pending'
|
|
49
|
-
&& tasks.some((task) => decision
|
|
50
|
+
&& tasks.some((task) => decisionTargetsTask(decision, task)))
|
|
50
51
|
const unresolved = tasks.some((task) => ['failed', 'reclaimed'].includes(task.status)
|
|
51
52
|
&& !failureHandled(task, tasks))
|
|
52
53
|
if (!tasks.some((task) => task.status === 'active') || pending || unresolved
|
package/aimlock-runtime.mjs
CHANGED
|
@@ -209,7 +209,7 @@ const RESPONSE_SCHEMA = "aimlock.skill.response/1.1";
|
|
|
209
209
|
const ERROR_SCHEMA = "aimlock.skill.error/1.0";
|
|
210
210
|
const CONTRACT_SCHEMA = "aimlock.scope-contract/1.0";
|
|
211
211
|
const COMPILER_NAME = "aimlock";
|
|
212
|
-
const COMPILER_VERSION = "v7.0.
|
|
212
|
+
const COMPILER_VERSION = "v7.0.39";
|
|
213
213
|
const KEEP_ALIVE_SECONDS = 90;
|
|
214
214
|
const KEEP_ALIVE_MESSAGE = "智能目标持续执行中,请勿关闭!";
|
|
215
215
|
const BYPASS_LINE_BUDGET = 500;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { stdin, stdout } from 'node:process'
|
|
2
|
+
import { executeCoordinatorOperation } from 'cli-swarm/coordinator'
|
|
3
|
+
import { errorRecord } from './aimlock-chain-model.mjs'
|
|
4
|
+
import { fail } from './aimlock-local-fs.mjs'
|
|
5
|
+
|
|
6
|
+
const TASK_INPUT_MAX_BYTES = 1_048_576
|
|
7
|
+
const MAX_TASK_DELIVERY_TIMEOUT_MS = 2_147_483_647
|
|
8
|
+
export const TASK_DELIVERY_TIMEOUT_MS = 30_000
|
|
9
|
+
export const TASK_OPERATIONS = Object.freeze([
|
|
10
|
+
'task-describe', 'task-checkpoint', 'task-resume', 'message-route', 'message-status',
|
|
11
|
+
'message-accept', 'message-complete', 'message-delivery-start', 'message-delivery-report',
|
|
12
|
+
'message-resolve', 'handoff-release', 'handoff-resume',
|
|
13
|
+
])
|
|
14
|
+
export const TASKS_USAGE = [
|
|
15
|
+
' cli-aimlock tasks <operation> <repositoryRoot> < task-message.json',
|
|
16
|
+
' cli-aimlock tasks capabilities <repositoryRoot>',
|
|
17
|
+
` Operations: ${TASK_OPERATIONS.join(', ')}.`,
|
|
18
|
+
' Message routing preserves the original plan. A resume package does not execute work.',
|
|
19
|
+
].join('\n')
|
|
20
|
+
|
|
21
|
+
function taskCapabilities(capabilities) {
|
|
22
|
+
if (!capabilities || !Array.isArray(capabilities.operations)
|
|
23
|
+
|| !capabilities.operationSchemas || typeof capabilities.operationSchemas !== 'object') {
|
|
24
|
+
fail('AIMLOCK_TASKS_CAPABILITIES_INVALID', 'coordinator task capabilities are invalid')
|
|
25
|
+
}
|
|
26
|
+
for (const operation of TASK_OPERATIONS) {
|
|
27
|
+
if (!capabilities.operations.includes(operation) || !Object.hasOwn(capabilities.operationSchemas, operation)) {
|
|
28
|
+
fail('AIMLOCK_TASKS_OPERATION_UNAVAILABLE', `coordinator task operation is unavailable: ${operation}`)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { operations: [...TASK_OPERATIONS], operationSchemas: Object.fromEntries(
|
|
32
|
+
TASK_OPERATIONS.map((operation) => [operation, capabilities.operationSchemas[operation]]),
|
|
33
|
+
) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readTaskInput(input) {
|
|
37
|
+
const chunks = []
|
|
38
|
+
let byteLength = 0
|
|
39
|
+
for await (const chunk of input) {
|
|
40
|
+
if (typeof chunk !== 'string' && !(chunk instanceof Uint8Array)) {
|
|
41
|
+
fail('AIMLOCK_TASKS_INPUT_INVALID', 'task input must be UTF-8 JSON')
|
|
42
|
+
}
|
|
43
|
+
const bytes = Buffer.from(chunk)
|
|
44
|
+
byteLength += bytes.byteLength
|
|
45
|
+
if (byteLength > TASK_INPUT_MAX_BYTES) fail('AIMLOCK_TASKS_INPUT_TOO_LARGE', 'task input exceeds 1 MiB')
|
|
46
|
+
chunks.push(bytes)
|
|
47
|
+
}
|
|
48
|
+
if (byteLength === 0) fail('AIMLOCK_TASKS_INPUT_REQUIRED', 'task input is required on stdin')
|
|
49
|
+
let value
|
|
50
|
+
try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))) }
|
|
51
|
+
catch { fail('AIMLOCK_TASKS_INPUT_INVALID', 'task input must be valid UTF-8 JSON') }
|
|
52
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
53
|
+
fail('AIMLOCK_TASKS_INPUT_INVALID', 'task input must be a JSON object')
|
|
54
|
+
}
|
|
55
|
+
return value
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function dispatchTasks(args, dependencies = {}) {
|
|
59
|
+
const [operation, repositoryRoot] = args
|
|
60
|
+
if (args.length !== 2 || (operation !== 'capabilities' && !TASK_OPERATIONS.includes(operation))) {
|
|
61
|
+
fail('AIMLOCK_TASKS_USAGE_INVALID', TASKS_USAGE)
|
|
62
|
+
}
|
|
63
|
+
const execute = dependencies.executeCoordinatorOperation ?? executeCoordinatorOperation
|
|
64
|
+
const input = dependencies.input ?? stdin
|
|
65
|
+
if (operation === 'capabilities') return taskCapabilities(await execute(operation, repositoryRoot, {}))
|
|
66
|
+
return execute(operation, repositoryRoot, await readTaskInput(input))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function runTasksCli(args) {
|
|
70
|
+
try {
|
|
71
|
+
const result = await dispatchTasks(args)
|
|
72
|
+
stdout.write(JSON.stringify(result) + '\n')
|
|
73
|
+
if (result.status === 'blocked' || result.status === 'waiting') process.exitCode = 2
|
|
74
|
+
else if (result.status === 'failed' || result.status === 'uncertain') process.exitCode = 1
|
|
75
|
+
} catch (error) {
|
|
76
|
+
stdout.write(JSON.stringify({ status: 'failed', error: errorRecord(error) }) + '\n')
|
|
77
|
+
process.exitCode = 1
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sourceIdentity(input) {
|
|
82
|
+
const identity = {}
|
|
83
|
+
for (const field of ['taskId', 'agentId', 'chainId']) {
|
|
84
|
+
if (typeof input?.[field] !== 'string' || !input[field].trim()) {
|
|
85
|
+
fail('AIMLOCK_TASKS_IDENTITY_REQUIRED', `task identity requires ${field}`)
|
|
86
|
+
}
|
|
87
|
+
identity[field] = input[field]
|
|
88
|
+
}
|
|
89
|
+
return identity
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function receiptMatches(receipt, delivery) {
|
|
93
|
+
return receipt && typeof receipt === 'object'
|
|
94
|
+
&& receipt.requestId === delivery.requestId && receipt.targetTaskId === delivery.targetTaskId
|
|
95
|
+
&& typeof receipt.receiptId === 'string' && receipt.receiptId.length > 0
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function deliveryStatus(execute, root, source, delivery) {
|
|
99
|
+
const status = await execute('message-status', root, { ...source, requestId: delivery.requestId })
|
|
100
|
+
if (!status || !Object.hasOwn(status, 'receipt')) {
|
|
101
|
+
fail('AIMLOCK_TASKS_RECEIPT_INVALID', 'coordinator message status has no receipt field')
|
|
102
|
+
}
|
|
103
|
+
if (status.receipt !== null && !receiptMatches(status.receipt, delivery)) {
|
|
104
|
+
fail('AIMLOCK_TASKS_RECEIPT_INVALID', 'coordinator receipt does not match the target delivery')
|
|
105
|
+
}
|
|
106
|
+
return status.receipt
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function awaitHostDelivery(adapter, delivery, timeoutMs) {
|
|
110
|
+
let timer
|
|
111
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
112
|
+
timer = setTimeout(() => reject(Object.assign(new Error(
|
|
113
|
+
`host delivery timed out after ${timeoutMs} ms; the actual delivery was not canceled`,
|
|
114
|
+
), { code: 'AIMLOCK_TASKS_DELIVERY_TIMEOUT' })), timeoutMs)
|
|
115
|
+
})
|
|
116
|
+
try {
|
|
117
|
+
return await Promise.race([Promise.resolve().then(() => adapter.deliver(delivery)), deadline])
|
|
118
|
+
} finally { clearTimeout(timer) }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function deliverTaskMessage(root, source, delivery, adapter, execute, timeoutMs) {
|
|
122
|
+
const recorded = await deliveryStatus(execute, root, source, delivery)
|
|
123
|
+
if (recorded) return { requestId: delivery.requestId, status: 'accepted', receipt: recorded, recovered: true }
|
|
124
|
+
const claim = await execute('message-delivery-start', root, { ...source, requestId: delivery.requestId })
|
|
125
|
+
if (!claim || typeof claim.claimed !== 'boolean'
|
|
126
|
+
|| (claim.claimed && (typeof claim.attemptId !== 'string' || !claim.attemptId))) {
|
|
127
|
+
fail('AIMLOCK_TASKS_DELIVERY_CLAIM_INVALID', 'coordinator delivery claim is invalid')
|
|
128
|
+
}
|
|
129
|
+
if (!claim.claimed) {
|
|
130
|
+
const receipt = await deliveryStatus(execute, root, source, delivery)
|
|
131
|
+
if (receipt) return { requestId: delivery.requestId, status: 'accepted', receipt, recovered: true }
|
|
132
|
+
fail('AIMLOCK_TASKS_DELIVERY_UNCERTAIN', 'delivery was already attempted; query its receipt before any further action')
|
|
133
|
+
}
|
|
134
|
+
let delivered
|
|
135
|
+
let deliveryError
|
|
136
|
+
try { delivered = await awaitHostDelivery(adapter, { ...delivery, attemptId: claim.attemptId }, timeoutMs) }
|
|
137
|
+
catch (error) { deliveryError = errorRecord(error) }
|
|
138
|
+
const receipt = await deliveryStatus(execute, root, source, delivery)
|
|
139
|
+
if (!receipt) {
|
|
140
|
+
const detail = deliveryError ? `: ${deliveryError.message}` : ''
|
|
141
|
+
fail('AIMLOCK_TASKS_DELIVERY_UNCERTAIN', `target acceptance has not been recorded${detail}`)
|
|
142
|
+
}
|
|
143
|
+
if (!deliveryError && (!receiptMatches(delivered, delivery) || delivered.status !== 'accepted'
|
|
144
|
+
|| delivered.receiptId !== receipt.receiptId)) {
|
|
145
|
+
fail('AIMLOCK_TASKS_RECEIPT_INVALID', 'adapter receipt does not match the recorded target acceptance')
|
|
146
|
+
}
|
|
147
|
+
return { requestId: delivery.requestId, status: 'accepted', receipt, recovered: Boolean(deliveryError),
|
|
148
|
+
...(deliveryError ? { deliveryError } : {}) }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function reportDeliveryFailure(root, source, delivery, error, execute, failures) {
|
|
152
|
+
const failure = errorRecord(error)
|
|
153
|
+
failures.push({ requestId: delivery.requestId, stage: 'delivery', error: failure })
|
|
154
|
+
try {
|
|
155
|
+
await execute('message-delivery-report', root, { ...source, requestId: delivery.requestId,
|
|
156
|
+
errorCode: typeof failure.code === 'string' ? failure.code : 'AIMLOCK_TASKS_DELIVERY_FAILED',
|
|
157
|
+
errorMessage: failure.message })
|
|
158
|
+
} catch (reportError) {
|
|
159
|
+
failures.push({ requestId: delivery.requestId, stage: 'delivery-report', error: errorRecord(reportError) })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A host adapter delivers messages; only the coordinator may issue durable acceptance receipts. */
|
|
164
|
+
export async function handleTaskMessage(root, input, adapter, { deliveryTimeoutMs = TASK_DELIVERY_TIMEOUT_MS } = {}) {
|
|
165
|
+
if (!adapter || typeof adapter.deliver !== 'function' || typeof adapter.continueTask !== 'function') {
|
|
166
|
+
fail('AIMLOCK_TASKS_ADAPTER_REQUIRED', 'host adapter requires deliver and continueTask functions')
|
|
167
|
+
}
|
|
168
|
+
if (!Number.isSafeInteger(deliveryTimeoutMs) || deliveryTimeoutMs <= 0
|
|
169
|
+
|| deliveryTimeoutMs > MAX_TASK_DELIVERY_TIMEOUT_MS) {
|
|
170
|
+
fail('AIMLOCK_TASKS_TIMEOUT_INVALID', 'deliveryTimeoutMs must be a positive integer within the timer limit')
|
|
171
|
+
}
|
|
172
|
+
const source = sourceIdentity(input)
|
|
173
|
+
const execute = executeCoordinatorOperation
|
|
174
|
+
const routed = await execute('message-route', root, input)
|
|
175
|
+
if (!routed || !Array.isArray(routed.deliveries)
|
|
176
|
+
|| Object.keys(source).some((field) => routed.source?.[field] !== source[field])) {
|
|
177
|
+
fail('AIMLOCK_TASKS_ROUTE_INVALID', 'coordinator message route does not match the source task')
|
|
178
|
+
}
|
|
179
|
+
const deliveries = []
|
|
180
|
+
const failures = []
|
|
181
|
+
for (const delivery of routed.deliveries) {
|
|
182
|
+
if (delivery.status !== 'pending-delivery') continue
|
|
183
|
+
try { deliveries.push(await deliverTaskMessage(root, source, delivery, adapter, execute, deliveryTimeoutMs)) }
|
|
184
|
+
catch (error) { await reportDeliveryFailure(root, source, delivery, error, execute, failures) }
|
|
185
|
+
}
|
|
186
|
+
let continuation = null
|
|
187
|
+
let continuationDispatched = false
|
|
188
|
+
try {
|
|
189
|
+
continuation = await execute('task-resume', root, source)
|
|
190
|
+
if (!continuation || typeof continuation.canContinue !== 'boolean'
|
|
191
|
+
|| Object.keys(source).some((field) => continuation[field] !== source[field])) {
|
|
192
|
+
fail('AIMLOCK_TASKS_RESUME_INVALID', 'coordinator resume package does not match the original task')
|
|
193
|
+
}
|
|
194
|
+
if (continuation.canContinue) {
|
|
195
|
+
await adapter.continueTask(continuation)
|
|
196
|
+
continuationDispatched = true
|
|
197
|
+
}
|
|
198
|
+
} catch (error) { failures.push({ requestId: null, stage: 'continuation', error: errorRecord(error) }) }
|
|
199
|
+
return { status: failures.length ? 'failed' : continuationDispatched ? 'continued' : 'waiting',
|
|
200
|
+
messageId: routed.messageId, requests: routed.requests, deliveries, continuation,
|
|
201
|
+
continuationDispatched, failures }
|
|
202
|
+
}
|
package/brain-client.mjs
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { dirname } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { loadOfficialSkillContext } from './installer.mjs'
|
|
4
|
+
import { prepareOfficialSkillUse, withUpgradeMetadata } from './official-skill-update.mjs'
|
|
1
5
|
import { createHash, randomUUID } from 'node:crypto'
|
|
2
6
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
7
|
import { inspectBrainTarget, brainStateDirectory, saveBrainRequest } from './brain-client-files.mjs'
|
|
@@ -38,6 +42,14 @@ function exact(value, keys, label) {
|
|
|
38
42
|
|
|
39
43
|
export async function invokeBrain(operation, input, dependencies = {}) {
|
|
40
44
|
if (!OPERATIONS.has(operation)) throw new Error('Unknown Brain operation')
|
|
45
|
+
let upgrade = null
|
|
46
|
+
if (operation !== 'status') {
|
|
47
|
+
const context = loadOfficialSkillContext(dirname(fileURLToPath(import.meta.url)))
|
|
48
|
+
const prepared = await prepareOfficialSkillUse(context, 'brain-client.mjs', dependencies)
|
|
49
|
+
if (prepared.module !== null) return withUpgradeMetadata(
|
|
50
|
+
await prepared.module.invokeBrain(operation, input, dependencies), prepared.upgrade)
|
|
51
|
+
upgrade = prepared.upgrade
|
|
52
|
+
}
|
|
41
53
|
const endpoint = dependencies.endpoint ?? BRAIN_ENDPOINT
|
|
42
54
|
const context = { endpoint, displayName: 'Brain planning' }
|
|
43
55
|
const environment = dependencies.environment ?? process.env
|
|
@@ -68,7 +80,7 @@ export async function invokeBrain(operation, input, dependencies = {}) {
|
|
|
68
80
|
if (payload.plan !== null && (payload.plan?.schemaVersion !== PLAN_SCHEMA
|
|
69
81
|
|| payload.plan.planId !== payload.planId || !HASH_PATTERN.test(payload.planDigest)
|
|
70
82
|
|| brainClientDigest(payload.plan) !== payload.planDigest)) throw new Error('Brain response plan digest is invalid')
|
|
71
|
-
return payload
|
|
83
|
+
return upgrade === null ? payload : withUpgradeMetadata(payload, upgrade)
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
export async function prepareBrainRequest(root, specification) {
|
|
@@ -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
|
+
}
|