@itpay/cli 2.1.40 → 2.1.44
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/dist/src/main.js +7 -8
- package/dist/src/services/rail/adapter.js +10 -1
- package/dist/src/services/registry.js +2 -2
- package/dist/src/state/account_auth.js +16 -9
- package/dist/src/state/config.js +2 -2
- package/dist/src/state/device_authority.js +271 -41
- package/docs/agent/buyer/install-and-setup.json +7 -6
- package/package.json +2 -2
- package/skills/itpay/SKILL.md +2 -0
package/dist/src/main.js
CHANGED
|
@@ -144,18 +144,15 @@ function reportCLIError(error, contract) {
|
|
|
144
144
|
reason: "读取 ItPay 身份边界;该错误需要用户或运营恢复 Backend 登记,不能通过换类型或删除本地身份绕过",
|
|
145
145
|
},
|
|
146
146
|
] : [];
|
|
147
|
-
const stateRecovery =
|
|
148
|
-
command: "itpay skill show itpay --json",
|
|
149
|
-
reason: "读取 Device 状态边界;修复当前 Host 的持久写权限后重试原命令",
|
|
150
|
-
}] : [];
|
|
147
|
+
const stateRecovery = [];
|
|
151
148
|
const lockRecovery = lockError ? [{
|
|
152
149
|
command: "itpay device repair-lock --json",
|
|
153
150
|
reason: "检查锁持有者;只回收已退出进程留下的锁,不改变设备身份",
|
|
154
151
|
}] : [];
|
|
155
152
|
const authorizationInstruction = stateError
|
|
156
|
-
?
|
|
153
|
+
? `本地设备状态操作未完成:${stateError.operation}(${stateError.causeCode},来源 ${stateError.source})。保持原身份和已有授权;由环境负责人核对该操作、锁持有者和宿主返回。权限错误才检查读写权限;未知操作结果先核对现场,不盲目重试,不删除身份或绕过宿主保护。`
|
|
157
154
|
: lockError
|
|
158
|
-
? "
|
|
155
|
+
? "本地身份锁占用或安全转换未完成。执行 recovery 核对;活跃持有者需等待,遗留转换需环境负责人检查。不能仅凭等待时间删锁或重建身份。"
|
|
159
156
|
: error instanceof HttpError && error.code === "agent_device_session_required"
|
|
160
157
|
? "CLI 已自动续期并重试同一请求一次,仍被拒绝;停止重试,不要切换 Agent Type 或旋转身份。"
|
|
161
158
|
: deviceError?.code === "agent_device_revoked"
|
|
@@ -174,7 +171,7 @@ function reportCLIError(error, contract) {
|
|
|
174
171
|
code: incompatible ? "backend_contract_incompatible" : backendOverrideError?.code ?? commandError?.code ?? (error instanceof HttpError ? error.code : transportError?.code ?? stateError?.code ?? lockError?.code ?? deviceError?.code ?? contract?.code ?? "command_failed"),
|
|
175
172
|
message: error instanceof Error ? error.message : String(error),
|
|
176
173
|
},
|
|
177
|
-
...(commandError?.result ? { result: commandError.result } : incompatible && error instanceof HttpError ? {
|
|
174
|
+
...(stateError ? { result: { failure_stage: "local_device_state", operation: stateError.operation, cause_code: stateError.causeCode, source: stateError.source } } : commandError?.result ? { result: commandError.result } : incompatible && error instanceof HttpError ? {
|
|
178
175
|
result: {
|
|
179
176
|
current_cli_version: CLI_VERSION,
|
|
180
177
|
current_api_contract_revision: API_CONTRACT_REVISION,
|
|
@@ -318,6 +315,8 @@ for (const action of ["login", "status"]) {
|
|
|
318
315
|
recovery: (result.recovery ?? []),
|
|
319
316
|
};
|
|
320
317
|
writeCommandEnvelope(envelope, { jsonOutput: Boolean(options.json) });
|
|
318
|
+
if (envelope.status === "auth_status_unknown")
|
|
319
|
+
process.exitCode = 1;
|
|
321
320
|
}
|
|
322
321
|
catch (error) {
|
|
323
322
|
reportCLIError(error, { jsonOutput: Boolean(options.json), code: "account_login_failed", initialization: true, instruction: "本轮授权检查未完成;保留设备及已有授权状态,按明确错误原因恢复。不要自动发起新的登录。" });
|
|
@@ -341,7 +340,7 @@ deviceCmd
|
|
|
341
340
|
writeCommandEnvelope({
|
|
342
341
|
status: status === "recovered" ? "device_lock_recovered" : status === "active" ? "device_lock_active" : "device_lock_absent",
|
|
343
342
|
result: { lock_status: status, device_identity_preserved: true },
|
|
344
|
-
instruction: status === "active" ? "
|
|
343
|
+
instruction: status === "active" ? "本地锁占用或安全转换未完成;保留原身份,核对持有者及宿主现场后继续原任务。" : "本地锁无需进一步处理;继续原任务。",
|
|
345
344
|
next: null,
|
|
346
345
|
recovery: [],
|
|
347
346
|
}, { jsonOutput: Boolean(options.json), plainResult: [`lock: ${status}`, "device_identity: preserved"] });
|
|
@@ -11,7 +11,6 @@ import { railCheckoutItinerary } from "./checkout.js";
|
|
|
11
11
|
import { renderRailResultPage } from "./page.js";
|
|
12
12
|
import { readSavedResult } from "./reading.js";
|
|
13
13
|
export const railAdapter = {
|
|
14
|
-
aftercare: { order: railAftercareOrder, directory: railAftercareList, present: railAftercareEnvelope, create: railAftercareCreate, respond: railAftercareRespond },
|
|
15
14
|
entryInstruction: RAIL_ENDPOINT_BRIEF + "按 result.guidance 逐项填写输入(when_to_use 说明本服务适用场景、input_fields 是必填契约、input_example 是待填示意,必须替换用户真实值),然后继续同一服务执行。不要臆造字段名。",
|
|
16
15
|
supported: true, inputTemplate: railInputTemplate, inputInstruction: railInputRecoveryInstruction,
|
|
17
16
|
guidance: railServiceGuidance, requiresGuidance: id => id === "itpay-rail-smart",
|
|
@@ -25,3 +24,13 @@ export const railAdapter = {
|
|
|
25
24
|
renderPage: (...args) => { renderRailResultPage(...args); return true; },
|
|
26
25
|
checkout: async (backend, model, id, token) => railCheckoutItinerary(model, await backend.getCheckoutPresentation(id, token)),
|
|
27
26
|
};
|
|
27
|
+
const bookingAdapter = {
|
|
28
|
+
...railAdapter,
|
|
29
|
+
aftercare: { order: railAftercareOrder, directory: railAftercareList, present: railAftercareEnvelope, create: railAftercareCreate, respond: railAftercareRespond },
|
|
30
|
+
};
|
|
31
|
+
export function railAdapterFor(identity) {
|
|
32
|
+
const booking = identity.public_binding
|
|
33
|
+
? identity.public_binding.operation_id === "prepare_purchase"
|
|
34
|
+
: identity.service_id === "itpay-rail-booking";
|
|
35
|
+
return booking ? bookingAdapter : railAdapter;
|
|
36
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { railAdapterFor } from "./rail/adapter.js";
|
|
2
2
|
import { schemaAdapter, unsupportedAdapter } from "./schema.js";
|
|
3
3
|
// Published identities are frozen compatibility contracts. Fields named
|
|
4
4
|
// endpoints/review/search, titles and ID prefixes never choose a business adapter.
|
|
@@ -27,5 +27,5 @@ export function serviceClientFeatures() {
|
|
|
27
27
|
}
|
|
28
28
|
export function serviceAdapter(execution) {
|
|
29
29
|
const key = serviceAdapterKey(execution);
|
|
30
|
-
return key === "rail.v1" ?
|
|
30
|
+
return key === "rail.v1" ? railAdapterFor(execution) : key === "schema.v1" ? schemaAdapter : unsupportedAdapter;
|
|
31
31
|
}
|
|
@@ -78,7 +78,7 @@ class AuthRequestError extends Error {
|
|
|
78
78
|
}
|
|
79
79
|
// AUTH03: transport/read uncertainty never maps to "denied" or a new session —
|
|
80
80
|
// the stored session stays on disk and the caller retries the same poll.
|
|
81
|
-
function authStatusUnknown(sessionID, error) {
|
|
81
|
+
function authStatusUnknown(sessionID, error, failureStage = "authorization_status_read") {
|
|
82
82
|
// These errors have definite compatibility/identity/state facts. Let the
|
|
83
83
|
// command boundary report them rather than turning them into polling.
|
|
84
84
|
if ((error instanceof HttpError && error.status < 500)
|
|
@@ -86,16 +86,23 @@ function authStatusUnknown(sessionID, error) {
|
|
|
86
86
|
|| error instanceof DeviceAuthorizationError || error instanceof DeviceStateError
|
|
87
87
|
|| error instanceof DeviceLockBusyError)
|
|
88
88
|
throw error;
|
|
89
|
+
const hostFailure = error && typeof error === "object" && "code" in error
|
|
90
|
+
&& (error.code === "CODEBUDDY_BROKER_DENY" || error.code === "CODEBUDDY_BROKER_OUTCOME_UNKNOWN");
|
|
89
91
|
const failure = error instanceof HttpTransportError
|
|
90
|
-
? { category: error.code, attempts: error.attempts }
|
|
91
|
-
:
|
|
92
|
+
? { category: error.code, attempts: error.attempts, source: "network_transport" }
|
|
93
|
+
: error instanceof HttpError || error instanceof AuthRequestError
|
|
94
|
+
? { category: "http_server_error", http_status: error instanceof HttpError ? error.status : error.httpStatus, source: "http_response" }
|
|
95
|
+
: hostFailure ? { category: "host_operation_unavailable", source: "host_broker" }
|
|
96
|
+
: { category: "authorization_status_unavailable", source: "unclassified" };
|
|
92
97
|
return {
|
|
93
98
|
status: 'auth_status_unknown',
|
|
94
|
-
result: { ...failure, ...(sessionID ? { dashboard_auth_session_id: sessionID } : {}) },
|
|
95
|
-
instruction:
|
|
96
|
-
? '
|
|
97
|
-
:
|
|
98
|
-
|
|
99
|
+
result: { ...failure, failure_stage: failureStage, ...(sessionID ? { dashboard_auth_session_id: sessionID } : {}) },
|
|
100
|
+
instruction: hostFailure || failure.source === 'unclassified'
|
|
101
|
+
? '本轮授权检查未完成,底层原因尚未确定;保留设备和已有授权,请维护方核对当前失败阶段及宿主结果,不自动登录、换身份或盲目重复修改。'
|
|
102
|
+
: sessionID
|
|
103
|
+
? '读取官方授权状态失败;原授权请求已保留。稍后重读同一状态,不要新建授权、不要清除本地登记。'
|
|
104
|
+
: '本轮账号绑定检查未完成,设备与绑定状态尚未确认;没有保存的授权请求。结束本轮,网络或服务恢复后可重新检查,不发起登录。',
|
|
105
|
+
next: sessionID && !hostFailure && failure.source !== 'unclassified' ? { command: 'itpay auth status --json', reason: '稍后重读同一授权状态', poll_after_ms: 5000 } : null,
|
|
99
106
|
recovery: [],
|
|
100
107
|
};
|
|
101
108
|
}
|
|
@@ -149,7 +156,7 @@ async function accountAuth(action, baseURL, env, fetcher, backend) {
|
|
|
149
156
|
throw error;
|
|
150
157
|
// AUTH03: even the binding-status probe can hit transport failure; the
|
|
151
158
|
// truthful state is "unknown", never an implicit unbound or a fresh login.
|
|
152
|
-
return authStatusUnknown(read(baseURL, env, purpose)?.sessionID, error);
|
|
159
|
+
return authStatusUnknown(read(baseURL, env, purpose)?.sessionID, error, "account_binding_probe");
|
|
153
160
|
}
|
|
154
161
|
}
|
|
155
162
|
async function request(path, init = {}) {
|
package/dist/src/state/config.js
CHANGED
|
@@ -15,8 +15,8 @@ import { OperationJournal } from "./operation_journal.js";
|
|
|
15
15
|
export const DEFAULT_BASE_URL = "https://app.itpay.ai";
|
|
16
16
|
export const DEV_BASE_URL = "https://dev.itpay.ai";
|
|
17
17
|
export const SANDBOX_BASE_URL = "https://sandbox.itpay.ai";
|
|
18
|
-
export const CLI_VERSION = "2.1.
|
|
19
|
-
export const API_CONTRACT_REVISION = "sha256:
|
|
18
|
+
export const CLI_VERSION = "2.1.44";
|
|
19
|
+
export const API_CONTRACT_REVISION = "sha256:c1e1f459c1d3844f6eeae21535b532abb2e48c3e320173c4faccd62992890dcd";
|
|
20
20
|
const CART_SESSION_DEFAULT_DIR = ".itpay-v3";
|
|
21
21
|
const CART_SESSION_FILENAME = "cart.json";
|
|
22
22
|
const OPERATION_JOURNAL_FILENAME = "operations.json";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign, } from "node:crypto";
|
|
2
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, lstatSync, linkSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
|
-
const PROTECTED_PATHS = ["/v1/agent-device-account-bindings", "/v1/carts", "/v1/service-executions", "/v1/agent-instances", "/v1/orders", "/v1/refunds", "/v1/me", "/v1/vault", "/v1/rail/phone-links"];
|
|
4
|
+
const PROTECTED_PATHS = ["/v1/agent-device-account-bindings", "/v1/carts", "/v1/service-executions", "/v1/agent-instances", "/v1/orders", "/v1/refunds", "/v1/me", "/v1/vault", "/v1/rail/phone-links", "/v1/rail-aftercare"];
|
|
5
5
|
export class DeviceAuthority {
|
|
6
6
|
baseURL;
|
|
7
7
|
backendKey;
|
|
@@ -101,7 +101,7 @@ export class DeviceAuthority {
|
|
|
101
101
|
}
|
|
102
102
|
catch (error) {
|
|
103
103
|
if (error.code !== "ENOENT") {
|
|
104
|
-
throw asDeviceStateError(error, "remove_private_key")
|
|
104
|
+
throw asDeviceStateError(error, "remove_private_key");
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
}
|
|
@@ -272,10 +272,7 @@ export class DeviceAuthority {
|
|
|
272
272
|
return undefined;
|
|
273
273
|
}
|
|
274
274
|
catch (error) {
|
|
275
|
-
|
|
276
|
-
if (stateError)
|
|
277
|
-
throw stateError;
|
|
278
|
-
return undefined;
|
|
275
|
+
throw asDeviceStateError(error, "read_state");
|
|
279
276
|
}
|
|
280
277
|
}
|
|
281
278
|
readPrivateKey() {
|
|
@@ -285,10 +282,7 @@ export class DeviceAuthority {
|
|
|
285
282
|
return createPrivateKey(readFileSync(this.privateKeyPath, "utf8"));
|
|
286
283
|
}
|
|
287
284
|
catch (error) {
|
|
288
|
-
|
|
289
|
-
if (stateError)
|
|
290
|
-
throw stateError;
|
|
291
|
-
return undefined;
|
|
285
|
+
throw asDeviceStateError(error, "read_private_key");
|
|
292
286
|
}
|
|
293
287
|
}
|
|
294
288
|
writeState(state) { atomicOwnerOnlyWrite(this.statePath, JSON.stringify(state, null, 2), "write_state"); }
|
|
@@ -309,19 +303,22 @@ export class DeviceStateError extends Error {
|
|
|
309
303
|
operation;
|
|
310
304
|
causeCode;
|
|
311
305
|
blockedPath;
|
|
312
|
-
code = "
|
|
306
|
+
code = "device_state_unavailable";
|
|
307
|
+
source;
|
|
313
308
|
constructor(operation, causeCode, blockedPath) {
|
|
314
309
|
super(`ItPay device state operation failed: ${operation} (${causeCode})`);
|
|
315
310
|
this.operation = operation;
|
|
316
311
|
this.causeCode = causeCode;
|
|
317
312
|
this.blockedPath = blockedPath;
|
|
318
313
|
this.name = "DeviceStateError";
|
|
314
|
+
this.source = causeCode === "UNCLASSIFIED_LOCAL_FAILURE" ? "unclassified_local_operation" : causeCode.startsWith("CODEBUDDY_BROKER_") ? "host_broker"
|
|
315
|
+
: ["LEGACY_LOCK_OWNER_UNKNOWN", "SYMLINK_LOCK", "LOCK_SOURCE_CHANGED", "LOCK_TRANSITION_BUSY", "LOCK_OWNER_PROBE_UNAVAILABLE"].includes(causeCode) ? "client_state_validation" : "native_filesystem";
|
|
319
316
|
}
|
|
320
317
|
}
|
|
321
318
|
export class DeviceLockBusyError extends Error {
|
|
322
319
|
code = "device_lock_busy";
|
|
323
320
|
constructor() {
|
|
324
|
-
super("ItPay device
|
|
321
|
+
super("ItPay device lock is occupied or its transition cannot be safely completed");
|
|
325
322
|
this.name = "DeviceLockBusyError";
|
|
326
323
|
}
|
|
327
324
|
}
|
|
@@ -359,7 +356,7 @@ function atomicOwnerOnlyWrite(path, value, operation) {
|
|
|
359
356
|
unlinkSync(temporary);
|
|
360
357
|
}
|
|
361
358
|
catch { /* best-effort cleanup */ }
|
|
362
|
-
throw asDeviceStatePathError(error, operation)
|
|
359
|
+
throw asDeviceStatePathError(error, operation);
|
|
363
360
|
}
|
|
364
361
|
}
|
|
365
362
|
async function withFileLock(path, run) {
|
|
@@ -367,46 +364,71 @@ async function withFileLock(path, run) {
|
|
|
367
364
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
368
365
|
}
|
|
369
366
|
catch (error) {
|
|
370
|
-
throw asDeviceStatePathError(error, "prepare_lock")
|
|
367
|
+
throw asDeviceStatePathError(error, "prepare_lock");
|
|
371
368
|
}
|
|
372
369
|
const ownerToken = `${process.pid}:${randomUUID()}`;
|
|
373
370
|
let acquired = false;
|
|
374
371
|
for (let attempt = 0; attempt < 800; attempt += 1) {
|
|
375
372
|
try {
|
|
376
|
-
|
|
377
|
-
|
|
373
|
+
if (!lockTransition(path, () => {
|
|
374
|
+
writeFileSync(path, ownerToken, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
375
|
+
chmodSync(path, 0o600);
|
|
376
|
+
})) {
|
|
377
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
378
380
|
acquired = true;
|
|
379
381
|
break;
|
|
380
382
|
}
|
|
381
383
|
catch (error) {
|
|
382
384
|
const code = error.code;
|
|
383
385
|
if (code !== "EEXIST")
|
|
384
|
-
throw asDeviceStateError(error, "acquire_lock")
|
|
386
|
+
throw asDeviceStateError(error, "acquire_lock");
|
|
385
387
|
inspectAndRecoverLock(path);
|
|
386
388
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
387
389
|
}
|
|
388
390
|
}
|
|
389
391
|
if (!acquired)
|
|
390
392
|
throw new DeviceLockBusyError();
|
|
393
|
+
let operationFailed = false;
|
|
391
394
|
try {
|
|
392
395
|
return await run();
|
|
393
396
|
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
operationFailed = true;
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
394
401
|
finally {
|
|
395
|
-
|
|
402
|
+
try {
|
|
403
|
+
await releaseLock(path, ownerToken);
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
if (!operationFailed)
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
396
409
|
}
|
|
397
410
|
}
|
|
398
411
|
function inspectAndRecoverLock(path) {
|
|
412
|
+
let result = "active";
|
|
413
|
+
lockTransition(path, () => { result = inspectLockOwner(path); });
|
|
414
|
+
return result;
|
|
415
|
+
}
|
|
416
|
+
function inspectLockOwner(path) {
|
|
399
417
|
let token;
|
|
400
418
|
let age;
|
|
419
|
+
let inspected;
|
|
401
420
|
try {
|
|
402
|
-
const lockStat =
|
|
421
|
+
const lockStat = lstatSync(path);
|
|
422
|
+
if (lockStat.isSymbolicLink())
|
|
423
|
+
throw new DeviceStateError("inspect_lock", "SYMLINK_LOCK");
|
|
424
|
+
inspected = lockStat;
|
|
403
425
|
age = Date.now() - lockStat.mtimeMs;
|
|
404
426
|
token = lockStat.isDirectory() ? "" : readFileSync(path, "utf8");
|
|
405
427
|
}
|
|
406
428
|
catch (error) {
|
|
407
429
|
if (error.code === "ENOENT")
|
|
408
430
|
return "absent";
|
|
409
|
-
throw asDeviceStateError(error, "inspect_lock")
|
|
431
|
+
throw asDeviceStateError(error, "inspect_lock");
|
|
410
432
|
}
|
|
411
433
|
const ownerPID = /^(\d+):[0-9a-f-]+$/.exec(token)?.[1];
|
|
412
434
|
if (ownerPID) {
|
|
@@ -419,8 +441,11 @@ function inspectAndRecoverLock(path) {
|
|
|
419
441
|
return "active";
|
|
420
442
|
}
|
|
421
443
|
}
|
|
422
|
-
else
|
|
423
|
-
|
|
444
|
+
else {
|
|
445
|
+
if (age <= 30_000)
|
|
446
|
+
return "active";
|
|
447
|
+
// Legacy ownerless locks cannot prove that an old caller has exited.
|
|
448
|
+
throw new DeviceStateError("inspect_lock", "LEGACY_LOCK_OWNER_UNKNOWN");
|
|
424
449
|
}
|
|
425
450
|
// Recheck the exact owner before moving the lock; another process may have renewed it.
|
|
426
451
|
try {
|
|
@@ -430,45 +455,250 @@ function inspectAndRecoverLock(path) {
|
|
|
430
455
|
catch (error) {
|
|
431
456
|
if (error.code === "ENOENT")
|
|
432
457
|
return "absent";
|
|
433
|
-
throw asDeviceStateError(error, "inspect_lock")
|
|
458
|
+
throw asDeviceStateError(error, "inspect_lock");
|
|
434
459
|
}
|
|
435
|
-
moveLockAside(path, "stale", "remove_stale_lock");
|
|
460
|
+
moveLockAside(path, "stale", "remove_stale_lock", inspected);
|
|
436
461
|
return "recovered";
|
|
437
462
|
}
|
|
438
|
-
function releaseLock(path, ownerToken) {
|
|
463
|
+
async function releaseLock(path, ownerToken) {
|
|
464
|
+
// Another caller may be inspecting our live owner under the same short
|
|
465
|
+
// transition. Wait for that critical section, without reclaiming its guard.
|
|
466
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
467
|
+
if (lockTransition(path, () => releaseOwnedLock(path, ownerToken)))
|
|
468
|
+
return;
|
|
469
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
470
|
+
}
|
|
471
|
+
throw new DeviceStateError("release_lock", "LOCK_TRANSITION_BUSY");
|
|
472
|
+
}
|
|
473
|
+
function releaseOwnedLock(path, ownerToken) {
|
|
439
474
|
try {
|
|
475
|
+
const inspected = lstatSync(path);
|
|
476
|
+
if (!inspected.isFile())
|
|
477
|
+
throw new DeviceStateError("release_lock", "LOCK_SOURCE_CHANGED");
|
|
440
478
|
if (readFileSync(path, "utf8") !== ownerToken)
|
|
441
479
|
return;
|
|
442
|
-
moveLockAside(path, "released", "release_lock");
|
|
480
|
+
moveLockAside(path, "released", "release_lock", inspected);
|
|
443
481
|
}
|
|
444
482
|
catch (error) {
|
|
445
483
|
if (error.code !== "ENOENT")
|
|
446
|
-
throw asDeviceStateError(error, "release_lock")
|
|
484
|
+
throw asDeviceStateError(error, "release_lock");
|
|
447
485
|
}
|
|
448
486
|
}
|
|
449
|
-
|
|
487
|
+
// Per-process tickets publish ownership in the directory name before any write.
|
|
488
|
+
// The pool itself is not a lock. An interrupted choosing/ready ticket is ignored
|
|
489
|
+
// only after its PID is proven dead; no caller removes another caller's ticket.
|
|
490
|
+
// New arrivals choose a larger ticket, and simultaneous choices use name order.
|
|
491
|
+
function lockTransition(path, run) {
|
|
492
|
+
const legacy = `${path}.transition`;
|
|
493
|
+
const pool = `${path}.transition-owners`;
|
|
494
|
+
mkdirSync(pool, { recursive: true, mode: 0o700 });
|
|
495
|
+
if (lstatSync(pool).isSymbolicLink())
|
|
496
|
+
throw new DeviceStateError("inspect_lock", "SYMLINK_LOCK");
|
|
497
|
+
const incarnation = processIncarnation(process.pid);
|
|
498
|
+
if (!incarnation)
|
|
499
|
+
throw new DeviceStateError("prepare_lock", "LOCK_OWNER_PROBE_UNAVAILABLE");
|
|
500
|
+
// mkdir publishes the choosing owner and its incarnation in one atomic step.
|
|
501
|
+
const name = `${process.pid}-${sha256(incarnation).slice(7)}-${randomUUID()}`;
|
|
502
|
+
const own = resolve(pool, name);
|
|
503
|
+
mkdirSync(own, { mode: 0o700 });
|
|
504
|
+
let failed = false;
|
|
505
|
+
let linked = false;
|
|
506
|
+
const token = JSON.stringify({ pid: process.pid, incarnation, token: randomUUID() });
|
|
450
507
|
try {
|
|
451
|
-
|
|
508
|
+
let ticket = 1;
|
|
509
|
+
for (const other of readdirSync(pool)) {
|
|
510
|
+
if (other === name || other === "archives")
|
|
511
|
+
continue;
|
|
512
|
+
const value = transitionTicket(pool, other);
|
|
513
|
+
if (value !== undefined)
|
|
514
|
+
ticket = Math.max(ticket, (value ?? 0) + 1);
|
|
515
|
+
}
|
|
516
|
+
if (!Number.isSafeInteger(ticket))
|
|
517
|
+
throw new DeviceStateError("inspect_lock", "LOCK_TRANSITION_BUSY");
|
|
518
|
+
writeFileSync(resolve(own, "ticket"), JSON.stringify({ sequence: ticket, incarnation }), { flag: "wx", mode: 0o600 });
|
|
519
|
+
for (const other of readdirSync(pool)) {
|
|
520
|
+
if (other === name || other === "archives")
|
|
521
|
+
continue;
|
|
522
|
+
const value = transitionTicket(pool, other);
|
|
523
|
+
if (value === null || (value !== undefined && (value < ticket || (value === ticket && other < name))))
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
// All new binaries hold the ticket mutex during stale recovery. Old binaries
|
|
527
|
+
// see EEXIST on this same path, so cannot race acquisition with recovery.
|
|
528
|
+
if (existsSync(legacy)) {
|
|
529
|
+
const stat = lstatSync(legacy);
|
|
530
|
+
if (!stat.isFile())
|
|
531
|
+
return false; // Unknown old directory owner stays protected.
|
|
532
|
+
const text = readFileSync(legacy, "utf8");
|
|
533
|
+
const old = /^(\d+):[0-9a-f-]+$/.exec(text);
|
|
534
|
+
let ownerPID;
|
|
535
|
+
let birth;
|
|
536
|
+
if (old)
|
|
537
|
+
ownerPID = Number(old[1]);
|
|
538
|
+
else {
|
|
539
|
+
let value;
|
|
540
|
+
try {
|
|
541
|
+
value = JSON.parse(text);
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
if (!value || typeof value !== "object" || !("pid" in value) || typeof value.pid !== "number" || !("incarnation" in value) || typeof value.incarnation !== "string")
|
|
547
|
+
return false;
|
|
548
|
+
ownerPID = value.pid;
|
|
549
|
+
birth = value.incarnation;
|
|
550
|
+
}
|
|
551
|
+
if (!Number.isSafeInteger(ownerPID) || ownerPID <= 0)
|
|
552
|
+
return false;
|
|
553
|
+
try {
|
|
554
|
+
process.kill(ownerPID, 0);
|
|
555
|
+
const current = birth && processIncarnation(ownerPID);
|
|
556
|
+
if (!current || current === birth)
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
if (error.code !== "ESRCH")
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
moveLockAside(legacy, "stale", "remove_stale_lock", stat);
|
|
564
|
+
}
|
|
565
|
+
const owner = resolve(own, "owner");
|
|
566
|
+
writeFileSync(owner, token, { flag: "wx", mode: 0o600 });
|
|
567
|
+
// Hard-link a fully written owner: no empty/unowned publication crash gap.
|
|
568
|
+
try {
|
|
569
|
+
linkSync(owner, legacy);
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
if (error.code === "EEXIST")
|
|
573
|
+
return false;
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
576
|
+
linked = true;
|
|
577
|
+
run();
|
|
578
|
+
return true;
|
|
452
579
|
}
|
|
453
580
|
catch (error) {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
581
|
+
failed = true;
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
584
|
+
finally {
|
|
585
|
+
try {
|
|
586
|
+
if (linked) {
|
|
587
|
+
if (readFileSync(legacy, "utf8") !== token)
|
|
588
|
+
throw new DeviceStateError("release_lock", "LOCK_SOURCE_CHANGED");
|
|
589
|
+
renameSync(legacy, resolve(own, "transition-released"));
|
|
590
|
+
}
|
|
591
|
+
const archive = resolve(pool, "archives");
|
|
592
|
+
mkdirSync(archive, { recursive: true, mode: 0o700 });
|
|
593
|
+
if (lstatSync(archive).isSymbolicLink())
|
|
594
|
+
throw new DeviceStateError("release_lock", "SYMLINK_LOCK");
|
|
595
|
+
const destination = resolve(archive, randomUUID());
|
|
596
|
+
mkdirSync(destination, { mode: 0o700 });
|
|
597
|
+
renameSync(own, resolve(destination, "entry"));
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
if (!failed)
|
|
601
|
+
throw asDeviceStateError(error, "release_lock");
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
// undefined = absent/dead, null = a live process still choosing its ticket.
|
|
606
|
+
function transitionTicket(pool, name) {
|
|
607
|
+
const owner = /^(\d+)-(?:([0-9a-f]{64})-)?[0-9a-f-]+$/.exec(name);
|
|
608
|
+
if (!owner || Number(owner[1]) <= 0)
|
|
609
|
+
throw new DeviceStateError("inspect_lock", "LOCK_SOURCE_CHANGED");
|
|
610
|
+
try {
|
|
611
|
+
process.kill(Number(owner[1]), 0);
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
if (error.code === "ESRCH")
|
|
615
|
+
return undefined;
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
if (owner[2]) {
|
|
619
|
+
const birth = processIncarnation(Number(owner[1]));
|
|
620
|
+
if (!birth)
|
|
621
|
+
return null;
|
|
622
|
+
if (sha256(birth).slice(7) !== owner[2])
|
|
623
|
+
return undefined;
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
const entry = resolve(pool, name);
|
|
627
|
+
if (!lstatSync(entry).isDirectory())
|
|
628
|
+
throw new DeviceStateError("inspect_lock", "SYMLINK_LOCK");
|
|
629
|
+
const file = resolve(entry, "ticket");
|
|
630
|
+
if (lstatSync(file).isSymbolicLink())
|
|
631
|
+
throw new DeviceStateError("inspect_lock", "SYMLINK_LOCK");
|
|
632
|
+
let value;
|
|
633
|
+
try {
|
|
634
|
+
value = JSON.parse(readFileSync(file, "utf8"));
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
return null;
|
|
460
638
|
}
|
|
461
|
-
|
|
639
|
+
if (!value || typeof value !== "object" || !("sequence" in value) || typeof value.sequence !== "number" || !("incarnation" in value) || typeof value.incarnation !== "string")
|
|
640
|
+
return null;
|
|
641
|
+
const birth = processIncarnation(Number(owner[1]));
|
|
642
|
+
if (!birth)
|
|
643
|
+
return null;
|
|
644
|
+
if (birth !== value.incarnation)
|
|
645
|
+
return undefined; // PID belongs to a different process.
|
|
646
|
+
return Number.isSafeInteger(value.sequence) && value.sequence > 0 ? value.sequence : null;
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
if (error.code === "ENOENT") {
|
|
650
|
+
return existsSync(resolve(pool, name)) ? null : undefined;
|
|
651
|
+
}
|
|
652
|
+
throw asDeviceStateError(error, "inspect_lock");
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// Built-in OS process metadata distinguishes reused PIDs without an age timeout.
|
|
656
|
+
// If probing is unavailable, ownership stays conservative rather than stolen.
|
|
657
|
+
function processIncarnation(pid) {
|
|
658
|
+
try {
|
|
659
|
+
if (process.platform === "linux") {
|
|
660
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
661
|
+
const started = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
|
|
662
|
+
return started ? `${readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()}:${started}` : undefined;
|
|
663
|
+
}
|
|
664
|
+
const output = process.platform === "win32"
|
|
665
|
+
? execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid}).StartTime.ToUniversalTime().ToString('o')`], { encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"] })
|
|
666
|
+
: execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, LC_ALL: "C", TZ: "UTC" } });
|
|
667
|
+
return output.trim() || undefined;
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
return undefined;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function moveLockAside(path, suffix, operation, source) {
|
|
674
|
+
try {
|
|
675
|
+
if (source.isSymbolicLink())
|
|
676
|
+
throw new DeviceStateError(operation, "SYMLINK_LOCK");
|
|
677
|
+
// Reserve a private, unique destination; never rename over shared archives.
|
|
678
|
+
const archive = `${path}.${suffix}.${randomUUID()}`;
|
|
679
|
+
mkdirSync(archive, { mode: 0o700 });
|
|
680
|
+
const current = lstatSync(path);
|
|
681
|
+
if (source.dev !== current.dev || source.ino !== current.ino || source.mtimeMs !== current.mtimeMs || source.size !== current.size) {
|
|
682
|
+
throw new DeviceStateError(operation, "LOCK_SOURCE_CHANGED");
|
|
683
|
+
}
|
|
684
|
+
renameSync(path, `${archive}/lock`);
|
|
685
|
+
}
|
|
686
|
+
catch (error) {
|
|
687
|
+
if (error.code === "ENOENT")
|
|
688
|
+
return;
|
|
689
|
+
throw asDeviceStateError(error, operation);
|
|
462
690
|
}
|
|
463
691
|
}
|
|
464
692
|
function asDeviceStateError(error, operation) {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
693
|
+
if (error instanceof DeviceStateError)
|
|
694
|
+
return error;
|
|
695
|
+
const code = error?.code;
|
|
696
|
+
const known = ["EACCES", "EPERM", "EROFS", "ENOTDIR", "EISDIR", "EEXIST", "ENOENT", "ENOTEMPTY", "ENOSPC", "EMFILE", "EIO", "CODEBUDDY_BROKER_DENY", "CODEBUDDY_BROKER_OUTCOME_UNKNOWN"];
|
|
697
|
+
return new DeviceStateError(operation, typeof code === "string" && known.includes(code) ? code : "UNCLASSIFIED_LOCAL_FAILURE");
|
|
469
698
|
}
|
|
470
699
|
function asDeviceStatePathError(error, operation) {
|
|
471
700
|
const code = error.code;
|
|
472
701
|
return code === "EEXIST" ? new DeviceStateError(operation, code, error.path) : asDeviceStateError(error, operation);
|
|
473
702
|
}
|
|
474
703
|
import { stateDir } from "./config.js";
|
|
704
|
+
import { execFileSync } from "node:child_process";
|
|
@@ -28,18 +28,19 @@
|
|
|
28
28
|
{
|
|
29
29
|
"intent": "verify the selected official Backend",
|
|
30
30
|
"command": "<execute the exact next.command returned by install>",
|
|
31
|
-
"success_signal": "
|
|
31
|
+
"success_signal": "ready proves Backend liveness only; retain the actual Agent Type and Backend, then verify device access"
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
|
-
"intent": "
|
|
35
|
-
"command": "
|
|
36
|
-
"success_signal": "
|
|
34
|
+
"intent": "verify device access after Backend liveness",
|
|
35
|
+
"command": "itpay auth status --json",
|
|
36
|
+
"success_signal": "authenticated or successful login_required proves device access; pending/unknown is not ready"
|
|
37
37
|
}
|
|
38
38
|
],
|
|
39
39
|
"agent_rules": [
|
|
40
40
|
"For first setup, read the complete Skill from this installed package before network checks. Select the actual Agent Type once through install and retain the same launcher and Backend. Do not reread the same version Skill, repeat initialization for ordinary tasks, or start login or business to prove setup.",
|
|
41
|
-
"
|
|
42
|
-
"
|
|
41
|
+
"Run installation separately and preserve full output and its exit code. Nonzero stops preparation even if an older CLI runs. Same-version reuse requires verified executable, registry channel target, contract and official source; do not force or bypass host protection.",
|
|
42
|
+
"Report ItPay ready only after Backend and device checks succeed; otherwise preparation is incomplete. Account login is not required for setup.",
|
|
43
|
+
"The current public setup link selects the channel and matching contract: latest/app for production, next/dev for beta. Do not let an old bundle or memory override this choice.",
|
|
43
44
|
"Production defaults to https://app.itpay.ai. Testing may set ITPAY_BACKEND_URL to the exact official URL the configured official test Backend only when explicitly requested; follow the CLI allowlist.",
|
|
44
45
|
"If Backend compatibility cannot be verified, stop this round. minimum_cli_version is a protocol floor, not a verified matching artifact. Ask the maintainer for the matching distribution; do not guess a version or downgrade.",
|
|
45
46
|
"Only install a replacement when its exact artifact and Backend contract have been verified; then reread that package Skill. Never change Agent Type or Device identity to recover compatibility.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@itpay/cli",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.44",
|
|
4
4
|
"description": "The ItPay CLI for services, orders, and human-authorized purchased content.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -77,5 +77,5 @@
|
|
|
77
77
|
"type": "git",
|
|
78
78
|
"url": "git+https://github.com/itpay-ai/cli.git"
|
|
79
79
|
},
|
|
80
|
-
"gitHead": "
|
|
80
|
+
"gitHead": "e857df88cab7549f3ee011fc4e562ef2d40f8274"
|
|
81
81
|
}
|
package/skills/itpay/SKILL.md
CHANGED
|
@@ -14,6 +14,8 @@ human the useful result or action, not the internal steps.
|
|
|
14
14
|
|
|
15
15
|
## Installation readiness
|
|
16
16
|
|
|
17
|
+
The current setup link chooses the channel: production is latest/app; beta is next/dev. Keep this choice even if old memory or a bundled CLI suggests another version. Run any installation separately, read its full output and preserve its exit code; a failed installation ends preparation even when an old CLI still runs. Reuse requires verified executable, current channel version, contract and official package source. Do not force installation or bypass host protection.
|
|
18
|
+
|
|
17
19
|
Read this installed package Skill before network checks. Select the actual Agent Type from `itpay install --json` and retain the same executable and Backend. readyz proves liveness only; after success, `itpay auth status --json` checks device access and binding. A successful authenticated or login_required response proves the device request worked; login_required does not require login during setup. Preserve any existing authorization. Unknown, missing-command, compatibility or exhausted network checks end this round without guessing commands/versions or reporting ready. minimum_cli_version is a protocol floor, not a verified artifact recommendation. Do not use services, orders or paid operations to prove installation. Already read same-version Skill needs no repeat.
|
|
18
20
|
|
|
19
21
|
## Choose one entry
|