cli-aimlock 7.0.35 → 7.0.37
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 +8 -2
- package/aimlock-chain-calls.mjs +24 -5
- package/aimlock-chain-cli.mjs +2 -1
- package/aimlock-chain-executor.mjs +28 -12
- package/aimlock-chain-model.mjs +1 -1
- package/aimlock-chain-store.mjs +2 -0
- package/aimlock-local-runner.mjs +9 -173
- package/aimlock-read-budget-renewal.mjs +198 -0
- package/aimlock-read-budget-schemas.mjs +31 -0
- package/aimlock-read-budget-state.mjs +71 -0
- package/aimlock-read-budget.mjs +127 -0
- package/aimlock-runtime.mjs +2 -2
- package/brain-client.mjs +2 -1
- package/broker-failures.mjs +68 -0
- package/broker-recovery.mjs +110 -0
- package/broker-transport-attempt.mjs +86 -0
- package/broker-transport.mjs +107 -0
- package/broker.mjs +67 -59
- package/cli.mjs +8 -0
- package/installer.mjs +23 -2
- package/package.json +10 -2
- package/skill/SKILL.md +24 -4
- package/skill/skill.json +1 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { appendAudit, ensureManagedDirectory, fail, identifier, managedPath, repositoryRoot } from './aimlock-local-fs.mjs'
|
|
4
|
+
|
|
5
|
+
const BUDGET_SCHEMA = 'aimlock.read-budget/1.0'
|
|
6
|
+
const TOKEN_ESTIMATE_ALGORITHM = 'utf8-bytes-div-4-ceil'
|
|
7
|
+
const READ_BUDGETS = Object.freeze({
|
|
8
|
+
lock: Object.freeze({ maxFiles: 3, maxTokenEstimate: null, maxDurationMs: 120_000 }),
|
|
9
|
+
probe: Object.freeze({ maxFiles: 10, maxTokenEstimate: 30_000, maxDurationMs: 480_000 }),
|
|
10
|
+
swarm: Object.freeze({ maxFiles: 30, maxTokenEstimate: 100_000, maxDurationMs: 3_600_000 }),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
async function readBudget(root, chainId) {
|
|
14
|
+
const id = identifier(chainId, 'chainId')
|
|
15
|
+
const path = managedPath(root, 'runs', id, 'read-budget.json')
|
|
16
|
+
const state = JSON.parse(await readFile(path, 'utf8'))
|
|
17
|
+
if (state.schemaVersion !== BUDGET_SCHEMA || state.chainId !== id) {
|
|
18
|
+
fail('AIMLOCK_BUDGET_INVALID', 'read budget authority is invalid')
|
|
19
|
+
}
|
|
20
|
+
return { path, state }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function budgetView(state, now = Date.now()) {
|
|
24
|
+
const elapsedMs = now - Date.parse(state.startedAt)
|
|
25
|
+
const remainingFiles = Math.max(0, state.maxFiles - state.uniqueFiles.length)
|
|
26
|
+
const remainingTokenEstimate = state.maxTokenEstimate === null ? null
|
|
27
|
+
: Math.max(0, state.maxTokenEstimate - state.tokenEstimate)
|
|
28
|
+
const remainingDurationMs = Math.max(0, state.maxDurationMs - elapsedMs)
|
|
29
|
+
const autoRenewEligible = !state.completedAt && remainingDurationMs === 0
|
|
30
|
+
&& remainingFiles > 0 && remainingTokenEstimate !== 0 && state.autoRenew?.status === 'active'
|
|
31
|
+
&& requiredRenewalIntervals(state, now) <= state.autoRenew.policy.maxRenewals - state.autoRenew.renewalCount
|
|
32
|
+
const decisionRequired = Boolean(state.completedAt) || remainingFiles === 0 || (remainingDurationMs === 0 && !autoRenewEligible)
|
|
33
|
+
|| remainingTokenEstimate === 0
|
|
34
|
+
return { ...state, elapsedMs, remainingFiles, remainingTokenEstimate, remainingDurationMs,
|
|
35
|
+
autoRenewEligible, decisionRequired,
|
|
36
|
+
nextActions: decisionRequired ? ['execute', 'plan', 'blocked'] : autoRenewEligible ? ['budget-read'] : [] }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requiredRenewalIntervals(state, now = Date.now()) {
|
|
40
|
+
if (!state.autoRenew) return null
|
|
41
|
+
return Math.max(0, Math.floor((now - Date.parse(state.startedAt) - state.maxDurationMs)
|
|
42
|
+
/ state.autoRenew.policy.intervalMs) + 1)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function initializeReadBudget(input) {
|
|
46
|
+
const root = await repositoryRoot(input.repositoryRoot)
|
|
47
|
+
const chainId = identifier(input.chainId, 'chainId')
|
|
48
|
+
const limits = READ_BUDGETS[input.mode]
|
|
49
|
+
if (!limits) fail('AIMLOCK_MODE_INVALID', 'mode must be lock, probe, or swarm')
|
|
50
|
+
const directory = await ensureManagedDirectory(root, 'runs', chainId)
|
|
51
|
+
const state = {
|
|
52
|
+
schemaVersion: BUDGET_SCHEMA,
|
|
53
|
+
chainId,
|
|
54
|
+
mode: input.mode,
|
|
55
|
+
startedAt: new Date().toISOString(),
|
|
56
|
+
...limits,
|
|
57
|
+
uniqueFiles: [],
|
|
58
|
+
readCalls: 0,
|
|
59
|
+
tokenEstimate: 0,
|
|
60
|
+
tokenEstimateAlgorithm: TOKEN_ESTIMATE_ALGORITHM,
|
|
61
|
+
extensions: [],
|
|
62
|
+
}
|
|
63
|
+
await writeFile(resolve(directory, 'read-budget.json'), `${JSON.stringify(state)}\n`, {
|
|
64
|
+
flag: 'wx', mode: 0o600,
|
|
65
|
+
})
|
|
66
|
+
await appendAudit(root, { event: 'read-budget-initialized', chainId, mode: input.mode })
|
|
67
|
+
return budgetView(state)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { BUDGET_SCHEMA, TOKEN_ESTIMATE_ALGORITHM, READ_BUDGETS, readBudget, budgetView,
|
|
71
|
+
requiredRenewalIntervals, initializeReadBudget }
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { relative } from 'node:path'
|
|
3
|
+
import { LOCAL_SCHEMA, appendAudit, atomicJson, fail, identifier, managedPath,
|
|
4
|
+
repositoryRoot, resolvedProjectPath, safeRelativePath, withFileLock } from './aimlock-local-fs.mjs'
|
|
5
|
+
import { assertChainNotSuspended } from './aimlock-coordination.mjs'
|
|
6
|
+
import { readBudget, budgetView } from './aimlock-read-budget-state.mjs'
|
|
7
|
+
import { assertReadBudgetActive, assertRenewalScope, renewReadBudgetTime } from './aimlock-read-budget-renewal.mjs'
|
|
8
|
+
|
|
9
|
+
const CONFIRMATION_SCHEMA = 'confirm-protocol.skill.response/1.0'
|
|
10
|
+
|
|
11
|
+
async function readFileWithinBudget(input) {
|
|
12
|
+
const root = await repositoryRoot(input.repositoryRoot)
|
|
13
|
+
const chainId = identifier(input.chainId, 'chainId')
|
|
14
|
+
await assertChainNotSuspended({ repositoryRoot: root, chainId })
|
|
15
|
+
const budgetPath = managedPath(root, 'runs', chainId, 'read-budget.json')
|
|
16
|
+
return withFileLock(budgetPath, async () => {
|
|
17
|
+
const authority = await readBudget(root, chainId)
|
|
18
|
+
const path = safeRelativePath(input.path)
|
|
19
|
+
let state = authority.state
|
|
20
|
+
await assertReadBudgetActive(root, state)
|
|
21
|
+
const before = budgetView(state)
|
|
22
|
+
const isNew = !state.uniqueFiles.includes(path)
|
|
23
|
+
if (isNew && before.remainingFiles === 0) fail('AIMLOCK_DECISION_REQUIRED', 'read file budget exhausted')
|
|
24
|
+
const projectFile = await resolvedProjectPath(root, path)
|
|
25
|
+
if (!projectFile.status.isFile()) fail('AIMLOCK_READ_NOT_FILE', `${path} is not a file`)
|
|
26
|
+
assertRenewalScope(state, path, relative(root, projectFile.target).split('\\').join('/'))
|
|
27
|
+
const tokenEstimate = Math.ceil(projectFile.status.size / 4)
|
|
28
|
+
if (before.remainingTokenEstimate !== null && tokenEstimate > before.remainingTokenEstimate) {
|
|
29
|
+
fail('AIMLOCK_DECISION_REQUIRED', 'read token estimate budget exhausted')
|
|
30
|
+
}
|
|
31
|
+
state = await renewReadBudgetTime(root, authority, path)
|
|
32
|
+
const content = await readFile(projectFile.target, 'utf8')
|
|
33
|
+
const updated = {
|
|
34
|
+
...state,
|
|
35
|
+
uniqueFiles: isNew ? [...state.uniqueFiles, path] : state.uniqueFiles,
|
|
36
|
+
readCalls: state.readCalls + 1,
|
|
37
|
+
tokenEstimate: state.tokenEstimate + tokenEstimate,
|
|
38
|
+
}
|
|
39
|
+
await atomicJson(authority.path, updated)
|
|
40
|
+
await appendAudit(root, { event: 'read-consumed', chainId, path, tokenEstimate })
|
|
41
|
+
return { schemaVersion: LOCAL_SCHEMA, path, content, budget: budgetView(updated) }
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function checkCachedReadAccess(input) {
|
|
46
|
+
const root = await repositoryRoot(input.repositoryRoot)
|
|
47
|
+
const chainId = identifier(input.chainId, 'chainId')
|
|
48
|
+
await assertChainNotSuspended({ repositoryRoot: root, chainId })
|
|
49
|
+
const budgetPath = managedPath(root, 'runs', chainId, 'read-budget.json')
|
|
50
|
+
return withFileLock(budgetPath, async () => {
|
|
51
|
+
const authority = await readBudget(root, chainId)
|
|
52
|
+
const { state } = authority
|
|
53
|
+
await assertReadBudgetActive(root, state)
|
|
54
|
+
const path = safeRelativePath(input.path)
|
|
55
|
+
const budget = budgetView(state)
|
|
56
|
+
if (budget.remainingTokenEstimate === 0) fail('AIMLOCK_DECISION_REQUIRED', 'read token estimate budget exhausted')
|
|
57
|
+
if (!state.uniqueFiles.includes(path)) fail('AIMLOCK_CACHE_UNCHARGED', 'cached source was not read by this chain')
|
|
58
|
+
const projectFile = await resolvedProjectPath(root, path)
|
|
59
|
+
assertRenewalScope(state, path, relative(root, projectFile.target).split('\\').join('/'))
|
|
60
|
+
return { schemaVersion: LOCAL_SCHEMA, path,
|
|
61
|
+
budget: budgetView(await renewReadBudgetTime(root, authority, path)) }
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readBudgetStatus(input) {
|
|
66
|
+
const root = await repositoryRoot(input.repositoryRoot)
|
|
67
|
+
return budgetView((await readBudget(root, input.chainId)).state)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function confirmedBudgetExtension(input) {
|
|
71
|
+
const confirmation = input.confirmation
|
|
72
|
+
const audit = confirmation?.auditEntry
|
|
73
|
+
const callback = confirmation?.callbackRequest
|
|
74
|
+
const payload = callback?.payload
|
|
75
|
+
const fields = ['files', 'tokenEstimate', 'durationMs']
|
|
76
|
+
if (!confirmation || confirmation.schemaVersion !== CONFIRMATION_SCHEMA || confirmation.status !== 'succeeded'
|
|
77
|
+
|| audit?.schemaVersion !== 'confirm.audit-entry/1.0' || audit.risk !== 'low' || audit.answer !== 'approve'
|
|
78
|
+
|| typeof audit.remembered !== 'boolean' || !Number.isFinite(Date.parse(audit.answeredAt))
|
|
79
|
+
|| callback?.operation !== 'budget-extend' || payload?.answer !== 'approve'
|
|
80
|
+
|| payload.chainId !== input.chainId || payload.requestId !== audit.requestId
|
|
81
|
+
|| !payload.additions || fields.some((key) => payload.additions[key] !== input.additions?.[key])) {
|
|
82
|
+
fail('AIMLOCK_CONFIRMATION_REQUIRED', 'a low-risk Confirm Protocol interaction-answer bound to this chain and exact additions is required')
|
|
83
|
+
}
|
|
84
|
+
identifier(audit.actorId, 'actorId')
|
|
85
|
+
identifier(audit.requestId, 'requestId')
|
|
86
|
+
return identifier(audit.auditId, 'auditId')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function extendReadBudget(input) {
|
|
90
|
+
const root = await repositoryRoot(input.repositoryRoot)
|
|
91
|
+
const confirmationId = confirmedBudgetExtension(input)
|
|
92
|
+
const additions = input.additions
|
|
93
|
+
if (!additions || !Number.isSafeInteger(additions.files) || additions.files < 0
|
|
94
|
+
|| !Number.isSafeInteger(additions.tokenEstimate) || additions.tokenEstimate < 0
|
|
95
|
+
|| !Number.isSafeInteger(additions.durationMs) || additions.durationMs < 0
|
|
96
|
+
|| additions.files + additions.tokenEstimate + additions.durationMs === 0) {
|
|
97
|
+
fail('AIMLOCK_EXTENSION_INVALID', 'budget additions must contain a positive integer increase')
|
|
98
|
+
}
|
|
99
|
+
const chainId = identifier(input.chainId, 'chainId')
|
|
100
|
+
const budgetPath = managedPath(root, 'runs', chainId, 'read-budget.json')
|
|
101
|
+
return withFileLock(budgetPath, async () => {
|
|
102
|
+
const authority = await readBudget(root, chainId)
|
|
103
|
+
const state = authority.state
|
|
104
|
+
await assertReadBudgetActive(root, state)
|
|
105
|
+
if (state.extensions.some((item) => item.confirmationId === confirmationId)) {
|
|
106
|
+
fail('AIMLOCK_CONFIRMATION_REPLAYED', 'this budget confirmation has already been applied')
|
|
107
|
+
}
|
|
108
|
+
const updated = {
|
|
109
|
+
...state,
|
|
110
|
+
maxFiles: state.maxFiles + additions.files,
|
|
111
|
+
maxTokenEstimate: state.maxTokenEstimate === null && additions.tokenEstimate === 0
|
|
112
|
+
? null : (state.maxTokenEstimate ?? 0) + additions.tokenEstimate,
|
|
113
|
+
maxDurationMs: state.maxDurationMs + additions.durationMs,
|
|
114
|
+
extensions: [...state.extensions, {
|
|
115
|
+
confirmationId,
|
|
116
|
+
additions,
|
|
117
|
+
at: new Date().toISOString(),
|
|
118
|
+
}],
|
|
119
|
+
}
|
|
120
|
+
await atomicJson(authority.path, updated)
|
|
121
|
+
await appendAudit(root, { event: 'read-budget-extended', chainId,
|
|
122
|
+
confirmationId, additions })
|
|
123
|
+
return budgetView(updated)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { checkCachedReadAccess, extendReadBudget, readBudgetStatus, readFileWithinBudget }
|
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.37";
|
|
213
213
|
const KEEP_ALIVE_SECONDS = 90;
|
|
214
214
|
const KEEP_ALIVE_MESSAGE = "智能目标持续执行中,请勿关闭!";
|
|
215
215
|
const BYPASS_LINE_BUDGET = 500;
|
|
@@ -1330,7 +1330,7 @@ async function executeRun(request) {
|
|
|
1330
1330
|
localTrustedExecution: {
|
|
1331
1331
|
requiredFor: ["filesystem-probe", "read-budget", "mutate-pass", "guarded-write", "autocoord-lease"],
|
|
1332
1332
|
operations: ["capabilities", "probe", "reassess", "budget-init", "budget-read", "budget-status",
|
|
1333
|
-
"budget-extend", "gate-issue", "gate-verify", "guarded-write"],
|
|
1333
|
+
"budget-extend", "budget-auto-renew-request", "budget-auto-renew", "budget-auto-renew-stop", "gate-issue", "gate-verify", "guarded-write"],
|
|
1334
1334
|
command: "cli-aimlock local <operation> <repositoryRoot>",
|
|
1335
1335
|
schemaDiscovery: "cli-aimlock local capabilities <repositoryRoot>",
|
|
1336
1336
|
boundary: "Only host writes routed through guarded-write are physically intercepted.",
|
package/brain-client.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { inspectBrainTarget, brainStateDirectory, saveBrainRequest } from './bra
|
|
|
4
4
|
export { inspectBrainTarget } from './brain-client-files.mjs'
|
|
5
5
|
import { resolve } from 'node:path'
|
|
6
6
|
import { brainClientAuthorization, transportFailureCode } from './broker.mjs'
|
|
7
|
+
import { createBrokerTransport } from './broker-transport.mjs'
|
|
7
8
|
import { executeCommand } from './aimlock-chain-process.mjs'
|
|
8
9
|
|
|
9
10
|
export const BRAIN_ENDPOINT = 'https://cli.tax/api/v1/brain'
|
|
@@ -45,7 +46,7 @@ export async function invokeBrain(operation, input, dependencies = {}) {
|
|
|
45
46
|
if (Buffer.byteLength(body) > MAX_INPUT_BYTES) throw new Error('Brain request exceeds the size limit')
|
|
46
47
|
let response
|
|
47
48
|
try {
|
|
48
|
-
response = await (dependencies.request ??
|
|
49
|
+
response = await (dependencies.request ?? createBrokerTransport({ environment }))(endpoint, {
|
|
49
50
|
method: 'POST', redirect: 'error', headers: { Authorization: authorization, 'Content-Type': 'application/json' },
|
|
50
51
|
body, signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
51
52
|
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const TRANSPORT_ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{1,63}$/
|
|
2
|
+
const NETWORK_TRANSPORT_ERROR = 'NETWORK_TRANSPORT'
|
|
3
|
+
const SKILL_INVOCATION_ERROR = 'SKILL_INVOCATION_FAILED'
|
|
4
|
+
const TRANSPORT_STAGES = new Set(['configuration', 'connecting', 'tls', 'request-sent', 'response-body', 'complete'])
|
|
5
|
+
const MAX_TRANSPORT_ATTEMPTS = 3
|
|
6
|
+
|
|
7
|
+
export function transportDiagnostics(value) {
|
|
8
|
+
if (!value || typeof value !== 'object' || !Number.isSafeInteger(value.attempts)
|
|
9
|
+
|| value.attempts < 0 || value.attempts > MAX_TRANSPORT_ATTEMPTS
|
|
10
|
+
|| !TRANSPORT_STAGES.has(value.stage) || typeof value.submitted !== 'boolean') return null
|
|
11
|
+
return { attempts: value.attempts, stage: value.stage, submitted: value.submitted }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class OfficialSkillInvocationError extends Error {
|
|
15
|
+
constructor(context, request, transportCode, stage, transport) {
|
|
16
|
+
super(`${context.displayName} ${request.operation} invocation failed: network transport ${transportCode}`)
|
|
17
|
+
this.name = 'OfficialSkillInvocationError'
|
|
18
|
+
this.code = NETWORK_TRANSPORT_ERROR
|
|
19
|
+
this.requestId = request.requestId
|
|
20
|
+
this.operation = request.operation
|
|
21
|
+
const diagnostic = transportDiagnostics(transport)
|
|
22
|
+
this.stage = diagnostic ? diagnostic.stage : stage
|
|
23
|
+
if (diagnostic) this.transport = diagnostic
|
|
24
|
+
this.retryable = false
|
|
25
|
+
this.transportCode = transportCode
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class OfficialSkillResponseError extends Error {
|
|
30
|
+
constructor(request, stage, message) {
|
|
31
|
+
super(message)
|
|
32
|
+
this.name = 'OfficialSkillResponseError'
|
|
33
|
+
this.code = SKILL_INVOCATION_ERROR
|
|
34
|
+
this.requestId = request.requestId
|
|
35
|
+
this.operation = request.operation
|
|
36
|
+
this.stage = stage
|
|
37
|
+
this.retryable = false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function transportFailureCode(error) {
|
|
42
|
+
const inspected = new Set()
|
|
43
|
+
let candidate = error
|
|
44
|
+
while (candidate && typeof candidate === 'object' && !inspected.has(candidate)) {
|
|
45
|
+
inspected.add(candidate)
|
|
46
|
+
const code = typeof candidate.code === 'string' ? candidate.code.trim() : ''
|
|
47
|
+
if (TRANSPORT_ERROR_CODE_PATTERN.test(code)) return code
|
|
48
|
+
const name = typeof candidate.name === 'string' ? candidate.name.trim() : ''
|
|
49
|
+
if (name === 'AbortError' || name === 'TimeoutError') return name
|
|
50
|
+
candidate = candidate.cause
|
|
51
|
+
}
|
|
52
|
+
return 'UNKNOWN_TRANSPORT_ERROR'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function officialSkillFailureResponse(error) {
|
|
56
|
+
if (error instanceof OfficialSkillInvocationError || error instanceof OfficialSkillResponseError) {
|
|
57
|
+
const failure = { code: error.code, message: error.message, requestId: error.requestId,
|
|
58
|
+
operation: error.operation, stage: error.stage, retryable: error.retryable }
|
|
59
|
+
if (typeof error.transportCode === 'string') failure.transportCode = error.transportCode
|
|
60
|
+
if (typeof error.receiptStatus === 'string') failure.receiptStatus = error.receiptStatus
|
|
61
|
+
if (error.recovery) failure.recovery = error.recovery
|
|
62
|
+
const diagnostic = transportDiagnostics(error.transport)
|
|
63
|
+
if (diagnostic) failure.transport = diagnostic
|
|
64
|
+
return { ok: false, error: failure }
|
|
65
|
+
}
|
|
66
|
+
return { ok: false, error: { code: SKILL_INVOCATION_ERROR,
|
|
67
|
+
message: error instanceof Error ? error.message : 'Skill invocation failed', retryable: false } }
|
|
68
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { OfficialSkillResponseError, transportFailureCode, transportDiagnostics } from './broker-failures.mjs'
|
|
2
|
+
|
|
3
|
+
export const SKILL_RECEIPT_SCHEMA = 'skill-runtime-receipt/1.0'
|
|
4
|
+
export const SKILL_RECEIPT_HEADER = 'X-Skill-Receipt'
|
|
5
|
+
const RECEIPT_API_PATH = '/api/v1/skill-invocations'
|
|
6
|
+
const QUERY_TIMEOUT_MS = 5_000
|
|
7
|
+
const QUERY_ATTEMPTS = 3
|
|
8
|
+
const QUERY_DELAY_MS = 250
|
|
9
|
+
const TERMINAL_STATUSES = new Set(['completed', 'failed'])
|
|
10
|
+
const RECEIPT_STATUSES = new Set([...TERMINAL_STATUSES, 'pending', 'expired', 'not-found'])
|
|
11
|
+
|
|
12
|
+
class ReceiptRecoveryError extends OfficialSkillResponseError {
|
|
13
|
+
constructor(request, status, message, transportCode) {
|
|
14
|
+
super(request, 'receipt-query', message)
|
|
15
|
+
this.name = 'ReceiptRecoveryError'
|
|
16
|
+
this.code = 'SKILL_INVOCATION_UNCERTAIN'
|
|
17
|
+
this.receiptStatus = status
|
|
18
|
+
if (transportCode) this.transportCode = transportCode
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function validateReceipt(response, payload, context, request) {
|
|
23
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)
|
|
24
|
+
|| payload.schemaVersion !== SKILL_RECEIPT_SCHEMA || payload.runtimeCode !== context.runtimeCode
|
|
25
|
+
|| payload.requestId !== request.requestId || !RECEIPT_STATUSES.has(payload.status)) {
|
|
26
|
+
throw new ReceiptRecoveryError(request, 'invalid', 'Receipt query returned invalid or mismatched execution identity')
|
|
27
|
+
}
|
|
28
|
+
const expectedHttpStatus = payload.status === 'pending' ? 202
|
|
29
|
+
: payload.status === 'expired' ? 410 : payload.status === 'not-found' ? 404 : 200
|
|
30
|
+
if (response.status !== expectedHttpStatus || (payload.status !== 'not-found'
|
|
31
|
+
&& !Number.isFinite(Date.parse(payload.expiresAt)))) {
|
|
32
|
+
throw new ReceiptRecoveryError(request, 'invalid', 'Receipt status or expiration metadata is invalid')
|
|
33
|
+
}
|
|
34
|
+
if (TERMINAL_STATUSES.has(payload.status) && (!Number.isInteger(payload.httpStatus)
|
|
35
|
+
|| payload.httpStatus < 100 || payload.httpStatus > 599 || !payload.response
|
|
36
|
+
|| typeof payload.response !== 'object' || Array.isArray(payload.response))) {
|
|
37
|
+
throw new ReceiptRecoveryError(request, 'invalid', 'Receipt has no valid recorded HTTP result')
|
|
38
|
+
}
|
|
39
|
+
if (payload.status === 'failed' && (payload.httpStatus < 400
|
|
40
|
+
|| typeof payload.response.error !== 'string' || typeof payload.response.code !== 'string')) {
|
|
41
|
+
throw new ReceiptRecoveryError(request, 'invalid', 'Failed receipt has no valid recorded error')
|
|
42
|
+
}
|
|
43
|
+
return payload
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function fetchReceipt(context, request, dependencies, authorization) {
|
|
47
|
+
const endpoint = new URL(context.endpoint)
|
|
48
|
+
const path = `${RECEIPT_API_PATH}/${encodeURIComponent(context.runtimeCode)}/${encodeURIComponent(request.requestId)}`
|
|
49
|
+
let response
|
|
50
|
+
let payload
|
|
51
|
+
try {
|
|
52
|
+
response = await dependencies.request(new URL(path, endpoint.origin).href, {
|
|
53
|
+
method: 'GET', redirect: 'error', headers: { Authorization: authorization },
|
|
54
|
+
signal: AbortSignal.timeout(QUERY_TIMEOUT_MS),
|
|
55
|
+
})
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new ReceiptRecoveryError(request, 'query-failed', 'Receipt query failed; execution outcome remains uncertain',
|
|
58
|
+
transportFailureCode(error))
|
|
59
|
+
}
|
|
60
|
+
if (response.status === 401 || response.status === 403) {
|
|
61
|
+
throw new ReceiptRecoveryError(request, 'unauthorized', `Receipt query authorization failed: HTTP ${response.status}`)
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
payload = await response.json()
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof SyntaxError) throw new ReceiptRecoveryError(request, 'invalid', 'Receipt query returned invalid JSON')
|
|
67
|
+
throw new ReceiptRecoveryError(request, 'query-failed', 'Receipt body was interrupted; execution outcome remains uncertain',
|
|
68
|
+
transportFailureCode(error))
|
|
69
|
+
}
|
|
70
|
+
return { receipt: validateReceipt(response, payload, context, request), transport: transportDiagnostics(response.transport) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function receiptOutcome(receipt, context, request, validateInvocation, transport) {
|
|
74
|
+
if (receipt.status === 'failed') {
|
|
75
|
+
throw new OfficialSkillResponseError(request, 'http-response',
|
|
76
|
+
`${context.displayName} ${request.operation} failed: HTTP ${receipt.httpStatus} (recorded receipt)`)
|
|
77
|
+
}
|
|
78
|
+
if (receipt.httpStatus !== 200 || receipt.response.ok !== true) {
|
|
79
|
+
throw new ReceiptRecoveryError(request, 'invalid', 'Completed receipt does not contain a successful HTTP response')
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const invocation = validateInvocation(receipt.response)
|
|
83
|
+
return { ...invocation, recovery: { status: 'completed', requestId: request.requestId },
|
|
84
|
+
...(transport ? { transport } : {}) }
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw new OfficialSkillResponseError(request, 'receipt-validation',
|
|
87
|
+
error instanceof Error ? error.message : 'Recorded skill response validation failed')
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function queryOfficialSkillReceipt(context, request, dependencies, authorization, validateInvocation) {
|
|
92
|
+
let lastFailure
|
|
93
|
+
for (let attempt = 0; attempt < QUERY_ATTEMPTS; attempt += 1) {
|
|
94
|
+
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, QUERY_DELAY_MS))
|
|
95
|
+
let fetched
|
|
96
|
+
try {
|
|
97
|
+
fetched = await fetchReceipt(context, request, dependencies, authorization)
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!(error instanceof ReceiptRecoveryError) || error.receiptStatus !== 'query-failed') throw error
|
|
100
|
+
lastFailure = error
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
const { receipt, transport } = fetched
|
|
104
|
+
if (TERMINAL_STATUSES.has(receipt.status)) return receiptOutcome(receipt, context, request, validateInvocation, transport)
|
|
105
|
+
lastFailure = new ReceiptRecoveryError(request, receipt.status,
|
|
106
|
+
`Invocation outcome is ${receipt.status}; query this requestId again before any new execution`)
|
|
107
|
+
if (receipt.status !== 'pending') throw lastFailure
|
|
108
|
+
}
|
|
109
|
+
throw lastFailure
|
|
110
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
function responseValue(message, chunks, state) {
|
|
2
|
+
const headers = new Headers()
|
|
3
|
+
for (let index = 0; index < message.rawHeaders.length; index += 2) {
|
|
4
|
+
headers.append(message.rawHeaders[index], message.rawHeaders[index + 1])
|
|
5
|
+
}
|
|
6
|
+
const status = message.statusCode
|
|
7
|
+
if (!Number.isInteger(status) || status < 200 || status > 599) throw new Error('BROKER_RESPONSE_STATUS_INVALID')
|
|
8
|
+
const body = Buffer.concat(chunks)
|
|
9
|
+
const emptyBody = status === 204 || status === 205 || status === 304
|
|
10
|
+
const response = new Response(emptyBody ? null : body, { status, headers })
|
|
11
|
+
response.transport = Object.freeze({ ...state, stage: 'complete' })
|
|
12
|
+
return response
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** No write/end/flushHeaders is permitted before the destination TLS socket is authenticated. */
|
|
16
|
+
export function attemptBrokerHttps(input, dependencies) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const { state, signal, failure } = dependencies
|
|
19
|
+
const agent = dependencies.createAgent({ ...input.agentOptions, keepAlive: false, maxSockets: 1, maxCachedSessions: 0 })
|
|
20
|
+
let request
|
|
21
|
+
let settled = false
|
|
22
|
+
let connectTimer
|
|
23
|
+
const cleanup = () => {
|
|
24
|
+
clearTimeout(connectTimer)
|
|
25
|
+
signal.removeEventListener('abort', abort)
|
|
26
|
+
agent.destroy()
|
|
27
|
+
}
|
|
28
|
+
const finishError = code => {
|
|
29
|
+
if (settled) return
|
|
30
|
+
settled = true
|
|
31
|
+
const error = failure(code)
|
|
32
|
+
request?.destroy()
|
|
33
|
+
cleanup()
|
|
34
|
+
reject(error)
|
|
35
|
+
}
|
|
36
|
+
const abort = () => finishError('ABORT_ERR')
|
|
37
|
+
const receive = response => {
|
|
38
|
+
state.stage = 'response-body'
|
|
39
|
+
const chunks = []
|
|
40
|
+
let bytes = 0
|
|
41
|
+
response.on('error', error => finishError(error.code))
|
|
42
|
+
response.on('aborted', () => finishError('BROKER_RESPONSE_TRUNCATED'))
|
|
43
|
+
response.on('close', () => { if (!response.complete) finishError('BROKER_RESPONSE_TRUNCATED') })
|
|
44
|
+
response.on('data', chunk => {
|
|
45
|
+
bytes += chunk.length
|
|
46
|
+
if (bytes > dependencies.maxResponseBytes) finishError('BROKER_RESPONSE_TOO_LARGE')
|
|
47
|
+
else chunks.push(chunk)
|
|
48
|
+
})
|
|
49
|
+
response.on('end', () => {
|
|
50
|
+
if (settled) return
|
|
51
|
+
if (!response.complete) return finishError('BROKER_RESPONSE_TRUNCATED')
|
|
52
|
+
const coding = response.headers['content-encoding']
|
|
53
|
+
if (coding !== undefined && coding !== 'identity') return finishError('BROKER_RESPONSE_ENCODING_UNSUPPORTED')
|
|
54
|
+
let value
|
|
55
|
+
try { value = responseValue(response, chunks, state) }
|
|
56
|
+
catch (error) { return finishError(error.message) }
|
|
57
|
+
settled = true
|
|
58
|
+
cleanup()
|
|
59
|
+
resolve(value)
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
request = dependencies.request(input.target, { method: input.method, headers: input.headers, agent,
|
|
64
|
+
rejectUnauthorized: true }, receive)
|
|
65
|
+
request.on('error', error => finishError(error.code))
|
|
66
|
+
request.once('socket', socket => {
|
|
67
|
+
state.stage = 'tls'
|
|
68
|
+
const send = () => {
|
|
69
|
+
if (settled) return
|
|
70
|
+
if (signal.aborted) return abort()
|
|
71
|
+
if (socket.encrypted !== true || socket.authorized !== true) return finishError('BROKER_TLS_UNAUTHORIZED')
|
|
72
|
+
clearTimeout(connectTimer)
|
|
73
|
+
state.stage = 'request-sent'
|
|
74
|
+
state.submitted = true
|
|
75
|
+
try { request.end(input.body) } catch (error) { finishError(error.code) }
|
|
76
|
+
}
|
|
77
|
+
if (socket.encrypted === true && socket.authorized === true) send()
|
|
78
|
+
else socket.once('secureConnect', send)
|
|
79
|
+
})
|
|
80
|
+
connectTimer = setTimeout(() => finishError('ETIMEDOUT'), dependencies.connectTimeoutMs)
|
|
81
|
+
connectTimer.unref()
|
|
82
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
83
|
+
if (signal.aborted) abort()
|
|
84
|
+
} catch (error) { finishError(error.code) }
|
|
85
|
+
})
|
|
86
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Agent, request as httpsRequest } from 'node:https'
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
3
|
+
import { attemptBrokerHttps } from './broker-transport-attempt.mjs'
|
|
4
|
+
|
|
5
|
+
export const BROKER_TRANSPORT_TIMEOUT_MS = 120_000
|
|
6
|
+
export const BROKER_TRANSPORT_CONNECT_TIMEOUT_MS = 8_000
|
|
7
|
+
export const BROKER_TRANSPORT_MAX_ATTEMPTS = 3
|
|
8
|
+
export const BROKER_TRANSPORT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
9
|
+
const RETRY_DELAY_MS = 150
|
|
10
|
+
const PROXY_KEYS = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'NO_PROXY', 'no_proxy']
|
|
11
|
+
const TRANSIENT_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENETUNREACH', 'EHOSTUNREACH'])
|
|
12
|
+
const CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,79}$/
|
|
13
|
+
|
|
14
|
+
export class BrokerTransportError extends Error {
|
|
15
|
+
constructor(code, transport) {
|
|
16
|
+
const safeCode = typeof code === 'string' && CODE_PATTERN.test(code) ? code : 'BROKER_TRANSPORT_FAILED'
|
|
17
|
+
super(`HTTPS broker transport failed (${safeCode})`)
|
|
18
|
+
this.name = 'BrokerTransportError'
|
|
19
|
+
this.code = safeCode
|
|
20
|
+
this.transport = Object.freeze({ ...transport })
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Native Agent proxyEnv: https://nodejs.org/api/https.html#new-agentoptions */
|
|
25
|
+
export function brokerProxyEnvironment(environment, nodeVersion = process.versions.node) {
|
|
26
|
+
const proxyEnv = {}
|
|
27
|
+
for (const key of PROXY_KEYS) {
|
|
28
|
+
if (environment[key] !== undefined) {
|
|
29
|
+
if (typeof environment[key] !== 'string') throw new Error('BROKER_PROXY_ENV_INVALID')
|
|
30
|
+
proxyEnv[key] = environment[key]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const hasProxy = PROXY_KEYS.slice(0, 4).some(key => typeof proxyEnv[key] === 'string' && proxyEnv[key].trim())
|
|
34
|
+
const version = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(nodeVersion)
|
|
35
|
+
if (!version) throw new Error('BROKER_NODE_VERSION_INVALID')
|
|
36
|
+
const major = Number(version[1]), minor = Number(version[2])
|
|
37
|
+
const supportsProxy = major > 24 || major === 24 && minor >= 5 || major === 22 && minor >= 21
|
|
38
|
+
if (hasProxy && !supportsProxy) throw new Error('BROKER_PROXY_UNSUPPORTED_NODE')
|
|
39
|
+
for (const key of PROXY_KEYS.slice(0, 4)) {
|
|
40
|
+
if (!proxyEnv[key]) continue
|
|
41
|
+
let proxy
|
|
42
|
+
try { proxy = new URL(proxyEnv[key]) } catch { throw new Error('BROKER_PROXY_ENV_INVALID') }
|
|
43
|
+
if (!['http:', 'https:'].includes(proxy.protocol)) throw new Error('BROKER_PROXY_ENV_INVALID')
|
|
44
|
+
}
|
|
45
|
+
return supportsProxy ? { proxyEnv } : {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function requestInput(url, options) {
|
|
49
|
+
const target = new URL(url)
|
|
50
|
+
if (target.protocol !== 'https:' || target.username || target.password) throw new Error('BROKER_HTTPS_REQUIRED')
|
|
51
|
+
const method = options.method === undefined ? 'GET' : options.method
|
|
52
|
+
if (typeof method !== 'string' || !/^[A-Z]+$/.test(method)) throw new Error('BROKER_METHOD_INVALID')
|
|
53
|
+
const headers = new Headers(options.headers)
|
|
54
|
+
headers.set('accept-encoding', 'identity')
|
|
55
|
+
const source = options.body
|
|
56
|
+
if (source !== undefined && source !== null && typeof source !== 'string' && !(source instanceof Uint8Array)) {
|
|
57
|
+
throw new Error('BROKER_BODY_INVALID')
|
|
58
|
+
}
|
|
59
|
+
const body = source === undefined || source === null ? undefined : Buffer.from(source)
|
|
60
|
+
if (body !== undefined) headers.set('content-length', String(body.length))
|
|
61
|
+
return { target, method, headers: Object.fromEntries(headers), body }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function overallSignal(external) {
|
|
65
|
+
const controller = new AbortController()
|
|
66
|
+
const abort = () => controller.abort(external.reason)
|
|
67
|
+
if (external?.aborted) controller.abort(external.reason)
|
|
68
|
+
else external?.addEventListener('abort', abort, { once: true })
|
|
69
|
+
const timer = setTimeout(() => controller.abort(new Error('ETIMEDOUT')), BROKER_TRANSPORT_TIMEOUT_MS)
|
|
70
|
+
timer.unref()
|
|
71
|
+
return { signal: controller.signal, cleanup() {
|
|
72
|
+
clearTimeout(timer)
|
|
73
|
+
external?.removeEventListener('abort', abort)
|
|
74
|
+
} }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Dependencies support isolated native TLS tests; production callers supply only environment. */
|
|
78
|
+
export function createBrokerTransport({ environment = process.env, request = httpsRequest,
|
|
79
|
+
createAgent = options => new Agent(options), nodeVersion = process.versions.node } = {}) {
|
|
80
|
+
return async function brokerRequest(url, options = {}) {
|
|
81
|
+
const state = { attempts: 0, stage: 'configuration', submitted: false }
|
|
82
|
+
let configured
|
|
83
|
+
try { configured = { ...requestInput(url, options), agentOptions: brokerProxyEnvironment(environment, nodeVersion) } }
|
|
84
|
+
catch (error) { throw new BrokerTransportError(error.message, state) }
|
|
85
|
+
const overall = overallSignal(options.signal)
|
|
86
|
+
try {
|
|
87
|
+
for (let number = 1; number <= BROKER_TRANSPORT_MAX_ATTEMPTS; number += 1) {
|
|
88
|
+
if (overall.signal.aborted) throw new BrokerTransportError('ABORT_ERR', state)
|
|
89
|
+
state.attempts = number
|
|
90
|
+
state.stage = 'connecting'
|
|
91
|
+
try {
|
|
92
|
+
return await attemptBrokerHttps(configured, { request, createAgent, signal: overall.signal, state,
|
|
93
|
+
connectTimeoutMs: BROKER_TRANSPORT_CONNECT_TIMEOUT_MS, maxResponseBytes: BROKER_TRANSPORT_MAX_RESPONSE_BYTES,
|
|
94
|
+
failure: code => new BrokerTransportError(code, state) })
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const failure = error instanceof BrokerTransportError ? error : new BrokerTransportError(error.code, state)
|
|
97
|
+
const reconnect = !state.submitted && !overall.signal.aborted && TRANSIENT_CODES.has(failure.code)
|
|
98
|
+
&& number < BROKER_TRANSPORT_MAX_ATTEMPTS
|
|
99
|
+
if (!reconnect) throw failure
|
|
100
|
+
try { await delay(RETRY_DELAY_MS, undefined, { signal: overall.signal }) }
|
|
101
|
+
catch { throw new BrokerTransportError('ABORT_ERR', state) }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
throw new BrokerTransportError('BROKER_TRANSPORT_ATTEMPTS_EXHAUSTED', state)
|
|
105
|
+
} finally { overall.cleanup() }
|
|
106
|
+
}
|
|
107
|
+
}
|