@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 CHANGED
@@ -144,18 +144,15 @@ function reportCLIError(error, contract) {
144
144
  reason: "读取 ItPay 身份边界;该错误需要用户或运营恢复 Backend 登记,不能通过换类型或删除本地身份绕过",
145
145
  },
146
146
  ] : [];
147
- const stateRecovery = stateError ? [{
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
- ? `当前运行环境无法写入 owner-only Device 状态;受阻路径 ${stateError.blockedPath ?? "~/.itpay-v3"},操作 ${stateError.operation}(${stateError.causeCode})。保持同一 Node、CLI 和 Agent Type,修复该路径持久写权限后重试原命令;不要手工创建 lock、删除 identity、随意更换 HOME 或换运行时。`
153
+ ? `本地设备状态操作未完成:${stateError.operation}(${stateError.causeCode},来源 ${stateError.source})。保持原身份和已有授权;由环境负责人核对该操作、锁持有者和宿主返回。权限错误才检查读写权限;未知操作结果先核对现场,不盲目重试,不删除身份或绕过宿主保护。`
157
154
  : lockError
158
- ? "另一个进程仍在更新本地身份。执行 recovery 检查;若持有者仍活跃,等待后继续原任务,不要删除锁或重建身份。"
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 { railAdapter } from "./rail/adapter.js";
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" ? railAdapter : key === "schema.v1" ? schemaAdapter : unsupportedAdapter;
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
- : { category: 'authorization_status_unavailable' };
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: sessionID
96
- ? '读取官方授权状态失败;原授权请求已保留。稍后重读同一状态,不要新建授权、不要清除本地登记。'
97
- : '本轮账号绑定检查未完成,设备与绑定状态尚未确认;没有保存的授权请求。结束本轮,网络或服务恢复后可重新检查,不发起登录。',
98
- next: sessionID ? { command: 'itpay auth status --json', reason: '稍后重读同一授权状态', poll_after_ms: 5000 } : null,
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 = {}) {
@@ -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.40";
19
- export const API_CONTRACT_REVISION = "sha256:700d9834de969f16d0fc58cbbdbaa092f969f497e30e4ec291c790cea3e7f73a";
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") ?? error;
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
- const stateError = asDeviceStateError(error, "read_state");
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
- const stateError = asDeviceStateError(error, "read_private_key");
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 = "device_state_unwritable";
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 identity is being updated by another process");
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) ?? error;
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") ?? error;
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
- writeFileSync(path, ownerToken, { encoding: "utf8", flag: "wx", mode: 0o600 });
377
- chmodSync(path, 0o600);
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") ?? error;
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
- releaseLock(path, ownerToken);
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 = statSync(path);
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") ?? error;
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 if (age <= 30_000) {
423
- return "active";
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") ?? error;
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") ?? error;
484
+ throw asDeviceStateError(error, "release_lock");
447
485
  }
448
486
  }
449
- function moveLockAside(path, suffix, operation) {
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
- renameSync(path, `${path}.${suffix}`);
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
- const code = error.code;
455
- if (code === "ENOENT")
456
- return;
457
- if (code === "EEXIST" || code === "ENOTEMPTY") {
458
- renameSync(path, `${path}.${suffix}.${randomUUID()}`);
459
- return;
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
- throw asDeviceStateError(error, operation) ?? error;
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
- const code = error.code;
466
- return code === "EACCES" || code === "EPERM" || code === "EROFS" || code === "ENOTDIR" || code === "EISDIR"
467
- ? new DeviceStateError(operation, code, error.path)
468
- : undefined;
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": "status is ready and next points to the packaged Skill with the same Agent Type and Backend"
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": "load the complete packaged operating contract",
35
- "command": "<execute the exact next.command returned by readyz>",
36
- "success_signal": "status is shown and next is null so the Agent can route the human's natural-language intent"
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
- "After setup, tell the human only that ItPay is ready; never ask them to run setup commands or explain technical setup steps.",
42
- "Use the CLI distribution already provided by the current npm package, Skill, or plugin; update only through that distribution's own instructions.",
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.40",
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": "c83c252d8799ae75f036c81738addd44026f191d"
80
+ "gitHead": "e857df88cab7549f3ee011fc4e562ef2d40f8274"
81
81
  }
@@ -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