@tokensrc/codex 1.14.16 → 1.14.17

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/commands.js CHANGED
@@ -16,7 +16,7 @@ import { executeCodexTokenPoolV4Delivery, reconcilePendingManagedCodexV3Operatio
16
16
  import { resolveManagedCodexV3AssignmentTarget } from "./managed-credential-v3-assignment.js";
17
17
  import { executeCodexTokenPoolV4Login } from "./token-pool-v4-upload-flow.js";
18
18
  import { readManagedCodexV3State } from "./managed-credential-v3.js";
19
- import { profileAccountTokenPoolV4Enabled, profileMultiDeviceCredentialSyncEnabled } from "./profile.js";
19
+ import { profileAccountTokenPoolV4Enabled, profileMultiDeviceCredentialSyncEnabled, profileRequestBoundDeviceCredentialsEnabled } from "./profile.js";
20
20
  import { superviseCodexV5Credentials, synchronizeCodexV5SelectionOnce } from "./multi-device-v5-flow.js";
21
21
  import { executeCodexAssignmentRetirementLocalDelete } from "./retirement-flow.js";
22
22
  import { clearCodexSelection, loadCodexSelection, rememberCodexSelectionProviderAccount, saveCodexSelection } from "./selection.js";
@@ -24,6 +24,9 @@ import { installCodexShortcuts, removeCodexShortcuts } from "./shortcuts.js";
24
24
  import { isCodexAccountAvailable, isCodexSubscriptionExpired, renderCodexAccountDashboard, selectCodexAccount } from "./tui.js";
25
25
  import { readCodexShortLivedDeliveryJournal } from "./short-lived-delivery-journal.js";
26
26
  import { superviseCodexShortLivedCredentials } from "./credential-supervisor.js";
27
+ import { ensureCodexV6DeviceCredentials, reconcileCodexV6DeviceAccess, reportCodexV6DeviceCredentials, requireAvailableCodexV6DeviceAccess } from "./request-bound-device-v6-flow.js";
28
+ import { getOrCreateCodexInstallationId } from "./multi-device-v5-state.js";
29
+ import { resetCodexV6LocalState } from "./request-bound-device-v6-state.js";
27
30
  export const NO_AVAILABLE_CHATGPT_ACCOUNT_MESSAGE = "当前用户没有绑定可用的 ChatGPT 账号。请前往 OA 系统提交 IT11 工单,申请独享或共享的 ChatGPT 个人账号。";
28
31
  // The Server refreshes a user-bound lineage only inside its final 60-second
29
32
  // credential safety window. Asking for a new delivery earlier would merely
@@ -69,14 +72,25 @@ function accountTokenPoolV4Enabled(config) {
69
72
  function multiDeviceCredentialSyncEnabled(config) {
70
73
  return profileMultiDeviceCredentialSyncEnabled(config.profile);
71
74
  }
75
+ function requestBoundDeviceCredentialsEnabled(config) {
76
+ return profileRequestBoundDeviceCredentialsEnabled(config.profile);
77
+ }
78
+ function assertRequestBoundDeviceCredentialsEnabled(config) {
79
+ if (requestBoundDeviceCredentialsEnabled(config))
80
+ return;
81
+ throw new CodexServiceError("平台尚未启用请求绑定的独立设备 Token Set。", "codex_request_bound_device_v6_not_enabled", 409);
82
+ }
72
83
  function codexConfigPathForEnv(env) {
73
84
  const codexHome = env.CODEX_HOME?.trim();
74
85
  return codexHome ? join(codexHome, "config.toml") : undefined;
75
86
  }
76
87
  function assertLegacyManagementEnabled(config) {
77
- if (!schemaV2LifecycleEnabled(config))
88
+ const requestBound = requestBoundDeviceCredentialsEnabled(config);
89
+ if (!schemaV2LifecycleEnabled(config) && !requestBound)
78
90
  return;
79
- throw new CodexServiceError("Schema-v2 user-bound credentials are enabled; legacy refresh-token pool management is disabled.", SCHEMA_V2_LEGACY_MANAGEMENT_DISABLED, 409);
91
+ throw new CodexServiceError(requestBound
92
+ ? "Request-bound per-device credentials are enabled; legacy direct assignment management is disabled."
93
+ : "Schema-v2 user-bound credentials are enabled; legacy refresh-token pool management is disabled.", SCHEMA_V2_LEGACY_MANAGEMENT_DISABLED, 409);
80
94
  }
81
95
  function assignmentCredentialStatus(assignment) {
82
96
  if (isCodexSubscriptionExpired(assignment))
@@ -175,6 +189,61 @@ function parseSubscriptionRenewalExpiry(value) {
175
189
  }
176
190
  return expiresAt;
177
191
  }
192
+ function parseRequestInstant(value, option) {
193
+ const normalized = value.trim();
194
+ const timestamp = Date.parse(normalized);
195
+ if (!/(?:Z|[+-]\d{2}:\d{2})$/iu.test(normalized) || !Number.isFinite(timestamp)) {
196
+ throw new CodexServiceError(`${option} 必须是带时区的 ISO-8601 时间。`, "invalid_codex_account_request");
197
+ }
198
+ return new Date(timestamp).toISOString();
199
+ }
200
+ function parsePositiveInteger(value, option, maximum = 32) {
201
+ const parsed = Number.parseInt(value, 10);
202
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > maximum || String(parsed) !== value.trim()) {
203
+ throw new CodexServiceError(`${option} 必须是 1 到 ${maximum} 之间的整数。`, "invalid_codex_account_request");
204
+ }
205
+ return parsed;
206
+ }
207
+ function parseNonNegativeInteger(value, option) {
208
+ const parsed = Number.parseInt(value, 10);
209
+ if (!Number.isInteger(parsed) || parsed < 0 || String(parsed) !== value.trim()) {
210
+ throw new CodexServiceError(`${option} 必须是非负整数。`, "invalid_codex_account_request");
211
+ }
212
+ return parsed;
213
+ }
214
+ function v6Platform() {
215
+ return ["darwin", "linux", "win32"].includes(process.platform)
216
+ ? process.platform : "unknown";
217
+ }
218
+ function formatV6Request(request) {
219
+ const expiresAt = Date.parse(request.validUntil ?? request.requestedValidUntil);
220
+ const remainingDays = Math.ceil((expiresAt - Date.now()) / 86_400_000);
221
+ const expiryNotice = remainingDays <= 0 ? "EXPIRED"
222
+ : remainingDays <= 7 ? `URGENT:${remainingDays}d`
223
+ : remainingDays <= 30 ? `EXPIRING:${remainingDays}d` : `${remainingDays}d`;
224
+ return `${request.id} ${request.status}/${request.availability}`
225
+ + ` user=${request.username} (${request.userSubject})`
226
+ + ` devices=${request.activeBindingCount}+${request.pendingBindingCount}`
227
+ + `/${request.approvedDeviceLimit ?? request.requestedDeviceCount}`
228
+ + ` valid-until=${request.validUntil ?? request.requestedValidUntil}`
229
+ + ` expiry=${expiryNotice}`
230
+ + ` version=${request.version}`;
231
+ }
232
+ function formatV6Binding(binding) {
233
+ return `${binding.id} ${binding.status} device=${binding.deviceLabel}`
234
+ + ` (${binding.installationId}) token-set=${binding.credentialLineageId ?? "WAITING"}`
235
+ + `${binding.accountExpiresAt ? ` account-expires=${binding.accountExpiresAt}` : ""}`
236
+ + `${binding.replacementDueAt ? ` replace-before=${binding.replacementDueAt}` : ""}`
237
+ + `${binding.predecessorBindingId ? ` replaces=${binding.predecessorBindingId}` : ""}`;
238
+ }
239
+ async function v6DeviceAccess(deps, options = {}, deviceLabel) {
240
+ return reconcileCodexV6DeviceAccess({
241
+ client: deps.clientFor(serviceConnection(options)),
242
+ tenantId: deps.config.connection.tenantId,
243
+ env: deps.env,
244
+ deviceLabel
245
+ });
246
+ }
178
247
  async function loadAccountData(deps, options = {}, allowV4Replacement = false) {
179
248
  const previous = await loadCodexSelection(deps.config.persistence.configDirectory);
180
249
  if (accountTokenPoolV4Enabled(deps.config)) {
@@ -417,6 +486,45 @@ async function installCredentials(deps, selection, service, allowV4Replacement =
417
486
  return { expiresAt: response.expiresAt, selection };
418
487
  }
419
488
  async function selectedCredentials(deps, requested, options = {}) {
489
+ if (requestBoundDeviceCredentialsEnabled(deps.config)) {
490
+ const client = deps.clientFor(serviceConnection(options));
491
+ const ensured = await ensureCodexV6DeviceCredentials({
492
+ client,
493
+ tenantId: deps.config.connection.tenantId,
494
+ env: deps.env
495
+ });
496
+ if (ensured.replacementDeferredWarningCode) {
497
+ deps.errorOutput.write(`新设备 Token Set 暂时无法交付 (${ensured.replacementDeferredWarningCode});`
498
+ + "本次继续使用尚有效的旧 Token Set,稍后会自动重试。\n");
499
+ }
500
+ const binding = ensured.binding;
501
+ if (requested && ![
502
+ binding.id,
503
+ binding.requestId,
504
+ binding.managedAccountId,
505
+ binding.providerAccountId,
506
+ binding.credentialLineageId
507
+ ].includes(requested)) {
508
+ throw new CodexServiceError("当前设备只能使用管理员为它绑定的独立 Token Set。", "codex_device_binding_not_found", 404);
509
+ }
510
+ const selection = await saveCodexSelection(deps.config.persistence.configDirectory, {
511
+ serviceUrl: options.server ?? deps.config.connection.serverUrl,
512
+ allowInsecurePoc: Boolean(options.insecurePoc || options.insecureHttp || deps.config.connection.allowInsecureHttp),
513
+ account: {
514
+ id: binding.managedAccountId,
515
+ accountId: binding.providerAccountId,
516
+ label: `${binding.deviceLabel} / ${binding.requestId}`,
517
+ distributionMode: "REQUEST_BOUND_DEVICE_V6",
518
+ managedAccountId: binding.managedAccountId,
519
+ providerAccountId: binding.providerAccountId,
520
+ requestId: binding.requestId,
521
+ bindingId: binding.id,
522
+ installationId: binding.installationId,
523
+ credentialLineageId: binding.credentialLineageId
524
+ }
525
+ });
526
+ return { selection, expiresAt: Date.parse(ensured.credentialExpiresAt) };
527
+ }
420
528
  if (accountTokenPoolV4Enabled(deps.config)) {
421
529
  const client = deps.clientFor(serviceConnection(options));
422
530
  const recovered = await reconcilePendingManagedCodexV3Operation({
@@ -592,6 +700,24 @@ async function selectedCredentials(deps, requested, options = {}) {
592
700
  return { selection, expiresAt: local.expiresAt };
593
701
  }
594
702
  async function launchWithSelectedCredentials(deps, selected, launch) {
703
+ if (requestBoundDeviceCredentialsEnabled(deps.config)) {
704
+ try {
705
+ return await launch();
706
+ }
707
+ finally {
708
+ const report = await reportCodexV6DeviceCredentials({
709
+ client: deps.clientFor({
710
+ serverUrl: selected.selection.serviceUrl,
711
+ allowInsecureHttp: selected.selection.allowInsecurePoc
712
+ }),
713
+ tenantId: deps.config.connection.tenantId,
714
+ env: deps.env
715
+ });
716
+ if (!report.reported && report.warningCode) {
717
+ deps.errorOutput.write(`Codex 独立设备 Token 代际上报暂缓 (${report.warningCode});其他设备不会受影响。\n`);
718
+ }
719
+ }
720
+ }
595
721
  if (multiDeviceCredentialSyncEnabled(deps.config)) {
596
722
  const client = deps.clientFor({
597
723
  serverUrl: selected.selection.serviceUrl,
@@ -677,18 +803,25 @@ export function registerCodexCommands(program, deps, options = {}) {
677
803
  else
678
804
  deps.output.write("Logged out.\n");
679
805
  });
680
- if (!includeAuthCommands && accountTokenPoolV4Enabled(deps.config))
681
- serviceOptions(codex.command("login")
682
- .description("Run an isolated official Codex login and enroll an unbound or explicitly assigned credential"))
683
- .option("--device-auth", "use the official Codex device authorization flow")
684
- .option("--assign-to <username-or-email>", "assign by exact username or email instead of adding to the unbound pool")
685
- .option("--assign-to-subject <uuid>", "assign directly to a canonical Keycloak subject UUID")
686
- .option("--yes", "confirm direct subject assignment")
687
- .action(async (opts, command) => {
806
+ if (!includeAuthCommands && accountTokenPoolV4Enabled(deps.config)) {
807
+ let managedLogin = serviceOptions(codex.command("login")
808
+ .description(requestBoundDeviceCredentialsEnabled(deps.config)
809
+ ? "Run an isolated official Codex login and add one complete token set to the unbound pool"
810
+ : "Run an isolated official Codex login and enroll an unbound or explicitly assigned credential"))
811
+ .option("--device-auth", "use the official Codex device authorization flow");
812
+ if (!requestBoundDeviceCredentialsEnabled(deps.config)) {
813
+ managedLogin = managedLogin
814
+ .option("--assign-to <username-or-email>", "assign by exact username or email instead of adding to the unbound pool")
815
+ .option("--assign-to-subject <uuid>", "assign directly to a canonical Keycloak subject UUID")
816
+ .option("--yes", "confirm direct subject assignment");
817
+ }
818
+ managedLogin.action(async (opts, command) => {
688
819
  if (!accountTokenPoolV4Enabled(deps.config)) {
689
820
  throw new CodexServiceError("The Codex v4 account token pool is not enabled by the verified platform profile.", "codex_token_pool_v4_not_enabled", 409);
690
821
  }
691
- const target = resolveManagedCodexV3AssignmentTarget(opts);
822
+ const target = requestBoundDeviceCredentialsEnabled(deps.config)
823
+ ? { mode: "UNBOUND_POOL" }
824
+ : resolveManagedCodexV3AssignmentTarget(opts);
692
825
  const result = await executeCodexTokenPoolV4Login({
693
826
  client: deps.clientFor(serviceConnection(opts)),
694
827
  tenantId: deps.config.connection.tenantId,
@@ -715,8 +848,131 @@ export function registerCodexCommands(program, deps, options = {}) {
715
848
  else
716
849
  deps.output.write(`Codex credential uploaded to token pool; status=${result.status}, placement=${result.placement}.\n`);
717
850
  });
851
+ }
852
+ const requests = codex.command("request")
853
+ .description("Apply for Codex access and manage request-bound device token sets");
854
+ serviceOptions(requests.command("create")
855
+ .description("Submit an account request and identify this device as the initial binding candidate")
856
+ .requiredOption("--reason <text>", "申请原因,至少 5 个字符")
857
+ .requiredOption("--until <timestamp>", "申请有效期结束时间,必须包含时区")
858
+ .option("--devices <count>", "申请的设备数量", "1")
859
+ .option("--device-label <label>", "当前设备名称")
860
+ .option("--idempotency-key <key>", "重试时复用同一个幂等键"))
861
+ .action(async (opts, command) => {
862
+ assertRequestBoundDeviceCredentialsEnabled(deps.config);
863
+ const requestedValidUntil = parseRequestInstant(opts.until, "--until");
864
+ if (Date.parse(requestedValidUntil) <= Date.now())
865
+ throw new CodexServiceError("--until 必须晚于当前时间。", "invalid_codex_account_request");
866
+ const installationId = await getOrCreateCodexInstallationId(deps.env);
867
+ const clientRequestId = randomUUID();
868
+ const result = await deps.clientFor(serviceConnection(opts)).createV6AccountRequest({
869
+ schemaVersion: 6,
870
+ clientRequestId,
871
+ reason: opts.reason,
872
+ requestedDeviceCount: parsePositiveInteger(opts.devices, "--devices"),
873
+ requestedValidUntil,
874
+ initialDevice: {
875
+ installationId,
876
+ label: (opts.deviceLabel?.trim() || hostname() || "Codex device").slice(0, 128),
877
+ platform: v6Platform()
878
+ }
879
+ }, opts.idempotencyKey?.trim() || clientRequestId);
880
+ if (await json(command, deps))
881
+ print(deps.output, success({
882
+ ...result, installationId
883
+ }));
884
+ else
885
+ deps.output.write(`申请已提交:${formatV6Request(result.request)}\n`
886
+ + `首台设备候选:${installationId}。审批后每个设备将获得独立完整 Token Set。\n`);
887
+ });
888
+ serviceOptions(requests.command("list").description("List the current user's Codex account requests"))
889
+ .action(async (opts, command) => {
890
+ assertRequestBoundDeviceCredentialsEnabled(deps.config);
891
+ const result = await deps.clientFor(serviceConnection(opts)).listV6AccountRequests();
892
+ if (await json(command, deps))
893
+ print(deps.output, success(result));
894
+ else if (result.items.length === 0)
895
+ deps.output.write("当前用户没有 ChatGPT/Codex 账号申请。\n");
896
+ else
897
+ for (const item of result.items)
898
+ deps.output.write(`${formatV6Request(item)}\n`);
899
+ });
900
+ serviceOptions(requests.command("show <requestId>").description("Show one account request"))
901
+ .action(async (requestId, opts, command) => {
902
+ assertRequestBoundDeviceCredentialsEnabled(deps.config);
903
+ const result = await deps.clientFor(serviceConnection(opts)).getV6AccountRequest(requestId);
904
+ if (await json(command, deps))
905
+ print(deps.output, success(result));
906
+ else
907
+ deps.output.write(`${formatV6Request(result.request)}\n`);
908
+ });
909
+ serviceOptions(requests.command("bindings <requestId>")
910
+ .description("List independent device token-set bindings under one request"))
911
+ .action(async (requestId, opts, command) => {
912
+ assertRequestBoundDeviceCredentialsEnabled(deps.config);
913
+ const result = await deps.clientFor(serviceConnection(opts)).listV6DeviceBindings(requestId);
914
+ if (await json(command, deps))
915
+ print(deps.output, success(result));
916
+ else if (result.items.length === 0)
917
+ deps.output.write("该申请尚无设备绑定。\n");
918
+ else
919
+ for (const binding of result.items)
920
+ deps.output.write(`${formatV6Binding(binding)}\n`);
921
+ });
922
+ serviceOptions(requests.command("reconcile")
923
+ .description("Reconcile this installation and automatically adopt one legacy distributed token set")
924
+ .option("--device-label <label>", "当前设备名称"))
925
+ .action(async (opts, command) => {
926
+ assertRequestBoundDeviceCredentialsEnabled(deps.config);
927
+ const access = await v6DeviceAccess(deps, opts, opts.deviceLabel);
928
+ if (await json(command, deps))
929
+ print(deps.output, success({
930
+ installationId: access.installationId, ...access.response
931
+ }));
932
+ else if (access.response.accessStatus === "AVAILABLE") {
933
+ const { request, binding } = requireAvailableCodexV6DeviceAccess(access);
934
+ deps.output.write(`${formatV6Request(request)}\n设备绑定:${binding.id} (${binding.installationId})`
935
+ + `,迁移结果=${access.response.migration}。\n`);
936
+ }
937
+ else
938
+ requireAvailableCodexV6DeviceAccess(access);
939
+ });
940
+ serviceOptions(requests.command("cancel <requestId>")
941
+ .description("Cancel a still-submitted account request")
942
+ .requiredOption("--version <number>", "当前请求版本")
943
+ .option("--idempotency-key <key>", "重试时复用同一个幂等键"))
944
+ .action(async (requestId, opts, command) => {
945
+ assertRequestBoundDeviceCredentialsEnabled(deps.config);
946
+ const result = await deps.clientFor(serviceConnection(opts)).cancelV6AccountRequest(requestId, { schemaVersion: 6, expectedRequestVersion: parseNonNegativeInteger(opts.version, "--version") }, opts.idempotencyKey?.trim() || randomUUID());
947
+ if (await json(command, deps))
948
+ print(deps.output, success(result));
949
+ else
950
+ deps.output.write(`申请已取消:${formatV6Request(result.request)}\n`);
951
+ });
718
952
  serviceOptions(codex.command("list").description("List available Codex accounts without downloading credentials"))
719
953
  .action(async (opts, command) => {
954
+ if (requestBoundDeviceCredentialsEnabled(deps.config)) {
955
+ const access = await v6DeviceAccess(deps, opts);
956
+ if (await json(command, deps)) {
957
+ print(deps.output, success({
958
+ credentialModel: "REQUEST_BOUND_DEVICE_V6",
959
+ installationId: access.installationId,
960
+ ...access.response
961
+ }));
962
+ return;
963
+ }
964
+ if (access.response.accessStatus !== "AVAILABLE") {
965
+ requireAvailableCodexV6DeviceAccess(access);
966
+ }
967
+ const { request, binding } = requireAvailableCodexV6DeviceAccess(access);
968
+ deps.output.write(`${formatV6Request(request)}\n`
969
+ + `${binding.id} device=${binding.deviceLabel} (${binding.installationId})`
970
+ + ` token-set=${binding.credentialLineageId} ${binding.status}\n`);
971
+ if (access.response.migration === "AUTO_MIGRATED") {
972
+ deps.output.write("既有本机凭据已自动转换为该申请下的第一个独立设备绑定。\n");
973
+ }
974
+ return;
975
+ }
720
976
  const { response: result, selectedAccountId, assignments, v3Assignments } = await loadAccountData(deps, opts);
721
977
  const hasAvailableAccount = result.accounts.some(isCodexAccountAvailable);
722
978
  const shouldRequestAccount = result.accounts.length === 0
@@ -739,6 +995,21 @@ export function registerCodexCommands(program, deps, options = {}) {
739
995
  serviceOptions(codex.command("use [account]").description("Select an account and update the official Codex auth.json"))
740
996
  .option("--takeover", "explicitly replace a v4 credential active on another host")
741
997
  .action(async (account, opts, command) => {
998
+ if (requestBoundDeviceCredentialsEnabled(deps.config)) {
999
+ if (opts.takeover)
1000
+ throw new CodexServiceError("v6 每台设备使用独立 Token Set,不支持接管其他设备的凭据。", "codex_device_takeover_not_allowed", 409);
1001
+ const selected = await selectedCredentials(deps, account, opts);
1002
+ if (await json(command, deps))
1003
+ print(deps.output, success({
1004
+ account: selected.selection.account,
1005
+ auth_mode: "chatgpt",
1006
+ credential_model: "REQUEST_BOUND_DEVICE_V6"
1007
+ }));
1008
+ else
1009
+ deps.output.write(`已安装当前设备的独立 Token Set:${selected.selection.account.label}`
1010
+ + ` (${selected.selection.account.bindingId})\n`);
1011
+ return;
1012
+ }
742
1013
  const v4 = accountTokenPoolV4Enabled(deps.config);
743
1014
  if (opts.takeover && !v4)
744
1015
  throw new CodexServiceError("Credential takeover requires the v4 token-pool capability.", "codex_token_pool_v4_not_enabled", 409);
@@ -825,6 +1096,7 @@ export function registerCodexCommands(program, deps, options = {}) {
825
1096
  if (schemaV2LifecycleEnabled(deps.config))
826
1097
  await discardCodexFileCredentialBackup(deps.env);
827
1098
  const auth = await resetManagedCodexFileCredentials(selection?.account.providerAccountId ?? selection?.account.accountId, deps.env);
1099
+ await resetCodexV6LocalState(deps.env);
828
1100
  await clearCodexSelection(deps.config.persistence.configDirectory);
829
1101
  if (await json(command, deps))
830
1102
  print(deps.output, success({ reset: true, auth_action: auth.action, legacy_provider_removed: cleanup.changed }));
@@ -852,6 +1124,40 @@ export function registerCodexCommands(program, deps, options = {}) {
852
1124
  throw new Error("auth-agent replaces the current login; add --replace-login to confirm.");
853
1125
  if (opts.credentialStore !== "file")
854
1126
  throw new Error("ChatGPT auth.json 同步目前仅支持 --credential-store file。");
1127
+ if (requestBoundDeviceCredentialsEnabled(deps.config)) {
1128
+ const interval = Number(opts.interval) * 1_000;
1129
+ if (!Number.isFinite(interval) || interval <= 0)
1130
+ throw new Error("--interval must be positive.");
1131
+ do {
1132
+ await ensureCodexV6DeviceCredentials({
1133
+ client: deps.clientFor(serviceConnection(opts)),
1134
+ tenantId: deps.config.connection.tenantId,
1135
+ env: deps.env
1136
+ });
1137
+ const result = await reportCodexV6DeviceCredentials({
1138
+ client: deps.clientFor(serviceConnection(opts)),
1139
+ tenantId: deps.config.connection.tenantId,
1140
+ env: deps.env
1141
+ });
1142
+ const event = {
1143
+ level: result.warningCode ? "WARN" : "INFO",
1144
+ event: result.reported
1145
+ ? "device_token_set.generation_reported"
1146
+ : "device_token_set.current",
1147
+ at: new Date().toISOString(),
1148
+ ...(result.warningCode ? { warningCode: result.warningCode } : {})
1149
+ };
1150
+ if (await json(command, deps))
1151
+ print(deps.output, success(event));
1152
+ else
1153
+ deps.output.write(`${event.at} ${event.level} ${event.event}`
1154
+ + `${result.warningCode ? ` (${result.warningCode})` : ""}; other devices unaffected\n`);
1155
+ if (opts.once)
1156
+ break;
1157
+ await new Promise((resolve) => setTimeout(resolve, interval));
1158
+ } while (true);
1159
+ return;
1160
+ }
855
1161
  if (accountTokenPoolV4Enabled(deps.config)) {
856
1162
  const interval = Number(opts.interval) * 1_000;
857
1163
  if (!Number.isFinite(interval) || interval <= 0)
@@ -1112,6 +1418,166 @@ export function registerCodexCommands(program, deps, options = {}) {
1112
1418
  }
1113
1419
  const admin = codex.command("admin")
1114
1420
  .description("Administer server-managed Codex accounts and assignments");
1421
+ const adminRequests = admin.command("requests")
1422
+ .description("Review account requests and create independent device token-set bindings");
1423
+ serviceOptions(adminRequests.command("list").description("List tenant requests, ordered by nearest expiry")
1424
+ .option("--expiring-within <days>", "只显示指定天数内到期的非终态申请"))
1425
+ .action(async (opts, command) => {
1426
+ assertRequestBoundDeviceCredentialsEnabled(deps.config);
1427
+ const expiresBefore = opts.expiringWithin
1428
+ ? new Date(Date.now() + parsePositiveInteger(opts.expiringWithin, "--expiring-within", 3_650) * 86_400_000).toISOString()
1429
+ : undefined;
1430
+ const result = await deps.clientFor(serviceConnection(opts)).listV6AdminAccountRequests(expiresBefore ? { expiresBefore, includeTerminal: false } : {});
1431
+ const items = [...result.items].sort((left, right) => Date.parse(left.validUntil ?? left.requestedValidUntil) - Date.parse(right.validUntil ?? right.requestedValidUntil));
1432
+ if (await json(command, deps))
1433
+ print(deps.output, success({ ...result, items }));
1434
+ else if (items.length === 0)
1435
+ deps.output.write(opts.expiringWithin
1436
+ ? `未来 ${opts.expiringWithin} 天内没有即将到期的 ChatGPT/Codex 申请。\n`
1437
+ : "当前租户没有 ChatGPT/Codex 账号申请。\n");
1438
+ else
1439
+ for (const item of items)
1440
+ deps.output.write(`${formatV6Request(item)}\n`);
1441
+ });
1442
+ serviceOptions(adminRequests.command("replacement-candidates")
1443
+ .description("List device bindings whose ChatGPT account needs pre-expiry replacement")
1444
+ .option("--within <days>", "替换观察窗口天数", "30"))
1445
+ .action(async (opts, command) => {
1446
+ assertRequestBoundDeviceCredentialsEnabled(deps.config);
1447
+ const replaceBefore = new Date(Date.now() + parsePositiveInteger(opts.within, "--within", 3_650) * 86_400_000).toISOString();
1448
+ const result = await deps.clientFor(serviceConnection(opts))
1449
+ .listV6AdminReplacementCandidates({ replaceBefore });
1450
+ const items = [...result.items].sort((left, right) => Date.parse(left.replaceBefore) - Date.parse(right.replaceBefore));
1451
+ if (await json(command, deps))
1452
+ print(deps.output, success({ ...result, items }));
1453
+ else if (items.length === 0) {
1454
+ deps.output.write(`未来 ${opts.within} 天没有需要提前替换的设备账号。\n`);
1455
+ }
1456
+ else
1457
+ for (const item of items) {
1458
+ deps.output.write(`${item.recommendedReason} replace-before=${item.replaceBefore} `
1459
+ + `request=${item.request.id} ${formatV6Binding(item.binding)}\n`);
1460
+ }
1461
+ });
1462
+ serviceOptions(adminRequests.command("show <requestId>").description("Show one tenant account request"))
1463
+ .action(async (requestId, opts, command) => {
1464
+ assertRequestBoundDeviceCredentialsEnabled(deps.config);
1465
+ const client = deps.clientFor(serviceConnection(opts));
1466
+ const [requestResult, bindingResult] = await Promise.all([
1467
+ client.getV6AdminAccountRequest(requestId),
1468
+ client.listV6AdminDeviceBindings(requestId)
1469
+ ]);
1470
+ if (await json(command, deps))
1471
+ print(deps.output, success({
1472
+ request: requestResult.request, bindings: bindingResult.items
1473
+ }));
1474
+ else {
1475
+ deps.output.write(`${formatV6Request(requestResult.request)}\n`);
1476
+ for (const binding of bindingResult.items) {
1477
+ deps.output.write(`- ${formatV6Binding(binding)}\n`);
1478
+ }
1479
+ }
1480
+ });
1481
+ serviceOptions(adminRequests.command("review <requestId>")
1482
+ .description("Approve or reject an account request")
1483
+ .requiredOption("--decision <decision>", "APPROVE 或 REJECT")
1484
+ .requiredOption("--reason <text>", "审批原因,至少 5 个字符")
1485
+ .requiredOption("--version <number>", "当前请求版本")
1486
+ .option("--device-limit <count>", "批准的设备数量")
1487
+ .option("--valid-from <timestamp>", "批准生效时间,必须包含时区")
1488
+ .option("--valid-until <timestamp>", "批准到期时间,必须包含时区")
1489
+ .option("--idempotency-key <key>", "重试时复用同一个幂等键"))
1490
+ .action(async (requestId, opts, command) => {
1491
+ assertRequestBoundDeviceCredentialsEnabled(deps.config);
1492
+ const decision = opts.decision.trim().toUpperCase();
1493
+ if (!["APPROVE", "REJECT"].includes(decision)) {
1494
+ throw new CodexServiceError("--decision 必须是 APPROVE 或 REJECT。", "invalid_codex_account_request_review");
1495
+ }
1496
+ const approve = decision === "APPROVE";
1497
+ if (approve && (!opts.deviceLimit || !opts.validFrom || !opts.validUntil)) {
1498
+ throw new CodexServiceError("批准申请必须同时提供 --device-limit、--valid-from 和 --valid-until。", "invalid_codex_account_request_review");
1499
+ }
1500
+ const validFrom = approve ? parseRequestInstant(opts.validFrom, "--valid-from") : null;
1501
+ const validUntil = approve ? parseRequestInstant(opts.validUntil, "--valid-until") : null;
1502
+ if (validFrom && validUntil && Date.parse(validUntil) <= Date.parse(validFrom)) {
1503
+ throw new CodexServiceError("--valid-until 必须晚于 --valid-from。", "invalid_codex_account_request_review");
1504
+ }
1505
+ const result = await deps.clientFor(serviceConnection(opts)).reviewV6AdminAccountRequest(requestId, {
1506
+ schemaVersion: 6,
1507
+ expectedRequestVersion: parseNonNegativeInteger(opts.version, "--version"),
1508
+ decision: decision,
1509
+ reason: opts.reason,
1510
+ approvedDeviceLimit: approve
1511
+ ? parsePositiveInteger(opts.deviceLimit, "--device-limit") : null,
1512
+ validFrom,
1513
+ validUntil
1514
+ }, opts.idempotencyKey?.trim() || randomUUID());
1515
+ if (await json(command, deps))
1516
+ print(deps.output, success(result));
1517
+ else
1518
+ deps.output.write(`审批完成:${formatV6Request(result.request)}\n`);
1519
+ });
1520
+ serviceOptions(adminRequests.command("bind-device <requestId>")
1521
+ .description("Bind another installation with its own independent complete token set")
1522
+ .requiredOption("--installation <id>", "用户设备上显示的 installationId")
1523
+ .requiredOption("--device-label <label>", "设备名称")
1524
+ .requiredOption("--version <number>", "当前请求版本")
1525
+ .option("--platform <platform>", "darwin|linux|win32|unknown", "unknown")
1526
+ .option("--pool-entry <id>", "立即预留的独立 Token Pool entry;省略则进入等待")
1527
+ .option("--idempotency-key <key>", "重试时复用同一个幂等键"))
1528
+ .action(async (requestId, opts, command) => {
1529
+ assertRequestBoundDeviceCredentialsEnabled(deps.config);
1530
+ const platform = opts.platform.trim().toLowerCase();
1531
+ if (!["darwin", "linux", "win32", "unknown"]
1532
+ .includes(platform)) {
1533
+ throw new CodexServiceError("--platform 必须是 darwin、linux、win32 或 unknown。", "invalid_codex_device_binding");
1534
+ }
1535
+ const result = await deps.clientFor(serviceConnection(opts)).createV6AdminDeviceBinding(requestId, {
1536
+ schemaVersion: 6,
1537
+ expectedRequestVersion: parseNonNegativeInteger(opts.version, "--version"),
1538
+ device: {
1539
+ installationId: opts.installation,
1540
+ label: opts.deviceLabel,
1541
+ platform: platform
1542
+ },
1543
+ poolEntryId: opts.poolEntry?.trim() || null
1544
+ }, opts.idempotencyKey?.trim() || randomUUID());
1545
+ if (await json(command, deps))
1546
+ print(deps.output, success(result));
1547
+ else
1548
+ deps.output.write(`设备已绑定:${result.binding.id} installation=${result.binding.installationId}`
1549
+ + ` token-set=${result.binding.credentialLineageId ?? "WAITING_FOR_TOKEN"}\n`);
1550
+ });
1551
+ serviceOptions(adminRequests.command("replace-device-token <requestId> <bindingId>")
1552
+ .description("Prepare an independent successor token set and switch only after device ACK")
1553
+ .requiredOption("--version <number>", "当前请求版本")
1554
+ .requiredOption("--reason <reason>", "SUBSCRIPTION_EXPIRING、CREDENTIAL_UNHEALTHY 或 ADMIN_REQUESTED")
1555
+ .requiredOption("--replace-before <timestamp>", "最迟替换时间,必须包含时区")
1556
+ .option("--pool-entry <id>", "立即预留的新独立 Token Pool entry;省略则进入等待")
1557
+ .option("--idempotency-key <key>", "重试时复用同一个幂等键"))
1558
+ .action(async (requestId, bindingId, opts, command) => {
1559
+ assertRequestBoundDeviceCredentialsEnabled(deps.config);
1560
+ const reason = opts.reason.trim().toUpperCase();
1561
+ if (!["SUBSCRIPTION_EXPIRING", "CREDENTIAL_UNHEALTHY", "ADMIN_REQUESTED"]
1562
+ .includes(reason)) {
1563
+ throw new CodexServiceError("--reason 必须是 SUBSCRIPTION_EXPIRING、CREDENTIAL_UNHEALTHY 或 ADMIN_REQUESTED。", "invalid_codex_device_replacement");
1564
+ }
1565
+ const result = await deps.clientFor(serviceConnection(opts)).replaceV6AdminDeviceToken(requestId, bindingId, {
1566
+ schemaVersion: 6,
1567
+ expectedRequestVersion: parseNonNegativeInteger(opts.version, "--version"),
1568
+ reason: reason,
1569
+ replaceBefore: parseRequestInstant(opts.replaceBefore, "--replace-before"),
1570
+ poolEntryId: opts.poolEntry?.trim() || null
1571
+ }, opts.idempotencyKey?.trim() || randomUUID());
1572
+ if (await json(command, deps))
1573
+ print(deps.output, success(result));
1574
+ else
1575
+ deps.output.write("已准备设备 Token 替换:current=" + result.currentBinding.id
1576
+ + " successor=" + result.replacementBinding.id
1577
+ + " status=" + result.replacementBinding.status
1578
+ + " token-set=" + (result.replacementBinding.credentialLineageId ?? "WAITING_FOR_TOKEN")
1579
+ + "。\n旧 Token Set 会保持有效,直到该设备安装并 ACK 新 Token Set。\n");
1580
+ });
1115
1581
  const adminAccounts = admin.command("accounts")
1116
1582
  .description("Inspect accounts, credential history, and refresh state");
1117
1583
  serviceOptions(adminAccounts.command("list").description("List all tenant Codex accounts"))