@tokensrc/codex 1.14.15 → 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/README.md +95 -2
- package/dist/client.d.ts +22 -1
- package/dist/client.js +228 -5
- package/dist/client.js.map +1 -1
- package/dist/command-directory.js +3 -2
- package/dist/command-directory.js.map +1 -1
- package/dist/commands.js +575 -21
- package/dist/commands.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/managed-credential-v3-envelope.d.ts +40 -1
- package/dist/managed-credential-v3-envelope.js +118 -1
- package/dist/managed-credential-v3-envelope.js.map +1 -1
- package/dist/managed-credential-v3-flow.js +105 -21
- package/dist/managed-credential-v3-flow.js.map +1 -1
- package/dist/profile.d.ts +2 -0
- package/dist/profile.js +26 -1
- package/dist/profile.js.map +1 -1
- package/dist/protocol.d.ts +1228 -0
- package/dist/protocol.js +516 -2
- package/dist/protocol.js.map +1 -1
- package/dist/request-bound-device-v6-flow.d.ts +42 -0
- package/dist/request-bound-device-v6-flow.js +403 -0
- package/dist/request-bound-device-v6-flow.js.map +1 -0
- package/dist/request-bound-device-v6-state.d.ts +397 -0
- package/dist/request-bound-device-v6-state.js +221 -0
- package/dist/request-bound-device-v6-state.js.map +1 -0
- package/dist/selection.d.ts +5 -0
- package/dist/selection.js +20 -2
- package/dist/selection.js.map +1 -1
- package/dist/token-pool-v4-upload-flow.js +5 -1
- package/dist/token-pool-v4-upload-flow.js.map +1 -1
- package/dist/tui.d.ts +7 -2
- package/dist/tui.js +21 -6
- package/dist/tui.js.map +1 -1
- package/package.json +2 -2
package/dist/commands.js
CHANGED
|
@@ -16,14 +16,17 @@ 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";
|
|
23
23
|
import { installCodexShortcuts, removeCodexShortcuts } from "./shortcuts.js";
|
|
24
|
-
import { isCodexAccountAvailable, renderCodexAccountDashboard, selectCodexAccount } from "./tui.js";
|
|
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,20 +72,52 @@ 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
|
-
|
|
88
|
+
const requestBound = requestBoundDeviceCredentialsEnabled(config);
|
|
89
|
+
if (!schemaV2LifecycleEnabled(config) && !requestBound)
|
|
78
90
|
return;
|
|
79
|
-
throw new CodexServiceError(
|
|
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) {
|
|
96
|
+
if (isCodexSubscriptionExpired(assignment))
|
|
97
|
+
return "EXPIRED";
|
|
82
98
|
if (assignment.deliveryEligible)
|
|
83
99
|
return "AVAILABLE";
|
|
84
100
|
return ["NEEDS_REAUTH", "LOST", "DIVERGED", "COMPROMISED"].includes(assignment.lineageStatus ?? "") ? "NEEDS_REAUTH" : "TEMPORARILY_UNAVAILABLE";
|
|
85
101
|
}
|
|
102
|
+
function assertCodexSubscriptionActive(subscription, label) {
|
|
103
|
+
if (!isCodexSubscriptionExpired(subscription))
|
|
104
|
+
return;
|
|
105
|
+
throw new CodexServiceError(`ChatGPT account ${label} has expired and must be renewed by an administrator.`, "codex_account_expired", 409);
|
|
106
|
+
}
|
|
107
|
+
function subscriptionSummary(subscription) {
|
|
108
|
+
const rawExpiresAt = subscription.subscriptionExpiresAt;
|
|
109
|
+
const expiresAt = typeof rawExpiresAt === "number"
|
|
110
|
+
? rawExpiresAt
|
|
111
|
+
: typeof rawExpiresAt === "string"
|
|
112
|
+
? Date.parse(rawExpiresAt)
|
|
113
|
+
: undefined;
|
|
114
|
+
return {
|
|
115
|
+
...(subscription.subscriptionStatus !== undefined
|
|
116
|
+
? { subscriptionStatus: subscription.subscriptionStatus }
|
|
117
|
+
: {}),
|
|
118
|
+
...(Number.isFinite(expiresAt) ? { subscriptionExpiresAt: expiresAt } : {})
|
|
119
|
+
};
|
|
120
|
+
}
|
|
86
121
|
function v4AssignmentMatchesLocalState(assignment, state) {
|
|
87
122
|
return state?.phase === "INSTALLED" && state.authorityMode === "CLIENT_MANAGED"
|
|
88
123
|
&& assignment.assignmentId === state.assignmentId
|
|
@@ -146,6 +181,69 @@ function formatAdminAccount(account) {
|
|
|
146
181
|
providerAccountId: account.providerAccountId
|
|
147
182
|
})} (${account.id})`;
|
|
148
183
|
}
|
|
184
|
+
function parseSubscriptionRenewalExpiry(value) {
|
|
185
|
+
const normalized = value.trim();
|
|
186
|
+
const expiresAt = Date.parse(normalized);
|
|
187
|
+
if (!/(?:Z|[+-]\d{2}:\d{2})$/iu.test(normalized) || !Number.isFinite(expiresAt)) {
|
|
188
|
+
throw new CodexServiceError("--expires-at must be an ISO-8601 timestamp with a timezone offset.", "invalid_codex_account_renewal");
|
|
189
|
+
}
|
|
190
|
+
return expiresAt;
|
|
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
|
+
}
|
|
149
247
|
async function loadAccountData(deps, options = {}, allowV4Replacement = false) {
|
|
150
248
|
const previous = await loadCodexSelection(deps.config.persistence.configDirectory);
|
|
151
249
|
if (accountTokenPoolV4Enabled(deps.config)) {
|
|
@@ -167,13 +265,16 @@ async function loadAccountData(deps, options = {}, allowV4Replacement = false) {
|
|
|
167
265
|
isDefault: previous?.account.distributionMode === "CLIENT_MANAGED_V4"
|
|
168
266
|
&& previous.account.assignmentId === assignment.assignmentId,
|
|
169
267
|
accessExpiresAt: Date.parse(assignment.credentialExpiresAt),
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
? "
|
|
173
|
-
:
|
|
174
|
-
|| assignment.
|
|
175
|
-
|
|
176
|
-
|
|
268
|
+
...subscriptionSummary(assignment),
|
|
269
|
+
credentialStatus: isCodexSubscriptionExpired(assignment)
|
|
270
|
+
? "EXPIRED"
|
|
271
|
+
: assignment.lineageStatus === "NEEDS_REAUTH"
|
|
272
|
+
|| assignment.refreshTokenHealth === "REJECTED"
|
|
273
|
+
? "NEEDS_REAUTH"
|
|
274
|
+
: locallyManaged || multiDevice && assignment.authorityMode === "CLIENT_MANAGED"
|
|
275
|
+
|| assignment.deliveryEligible
|
|
276
|
+
&& (allowV4Replacement || !replacementCandidate)
|
|
277
|
+
? "AVAILABLE" : "TEMPORARILY_UNAVAILABLE",
|
|
177
278
|
lineageStatus: assignment.authorityMode === "CLIENT_MANAGED" && !locallyManaged
|
|
178
279
|
? multiDevice && assignment.lineageStatus !== "STALE"
|
|
179
280
|
? assignment.lineageStatus : "SYNC_LAGGING"
|
|
@@ -199,6 +300,7 @@ async function loadAccountData(deps, options = {}, allowV4Replacement = false) {
|
|
|
199
300
|
...(assignment.credentialExpiresAt
|
|
200
301
|
? { accessExpiresAt: Date.parse(assignment.credentialExpiresAt) }
|
|
201
302
|
: {}),
|
|
303
|
+
...subscriptionSummary(assignment),
|
|
202
304
|
credentialStatus: assignmentCredentialStatus(assignment),
|
|
203
305
|
lineageStatus: assignment.lineageStatus ?? undefined,
|
|
204
306
|
quota: null
|
|
@@ -384,6 +486,45 @@ async function installCredentials(deps, selection, service, allowV4Replacement =
|
|
|
384
486
|
return { expiresAt: response.expiresAt, selection };
|
|
385
487
|
}
|
|
386
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
|
+
}
|
|
387
528
|
if (accountTokenPoolV4Enabled(deps.config)) {
|
|
388
529
|
const client = deps.clientFor(serviceConnection(options));
|
|
389
530
|
const recovered = await reconcilePendingManagedCodexV3Operation({
|
|
@@ -398,6 +539,7 @@ async function selectedCredentials(deps, requested, options = {}) {
|
|
|
398
539
|
|| assignment.authorityMode !== "CLIENT_MANAGED") {
|
|
399
540
|
throw new CodexServiceError("The recovered local v4 lineage no longer matches the server assignment.", "lineage_version_conflict", 409);
|
|
400
541
|
}
|
|
542
|
+
assertCodexSubscriptionActive(assignment, assignment.label);
|
|
401
543
|
const selection = await saveCodexSelection(deps.config.persistence.configDirectory, {
|
|
402
544
|
serviceUrl: options.server ?? deps.config.connection.serverUrl,
|
|
403
545
|
allowInsecurePoc: Boolean(options.insecurePoc || options.insecureHttp || deps.config.connection.allowInsecureHttp),
|
|
@@ -425,6 +567,7 @@ async function selectedCredentials(deps, requested, options = {}) {
|
|
|
425
567
|
if (!assignment) {
|
|
426
568
|
throw new CodexServiceError("The selected v5 assignment is no longer active.", "codex_assignment_not_found", 404);
|
|
427
569
|
}
|
|
570
|
+
assertCodexSubscriptionActive(assignment, assignment.label);
|
|
428
571
|
if (assignment.authorityMode !== "CLIENT_MANAGED") {
|
|
429
572
|
return persistInstalledSelection(deps, await installCredentials(deps, staged, serviceConnection(options)));
|
|
430
573
|
}
|
|
@@ -458,6 +601,7 @@ async function selectedCredentials(deps, requested, options = {}) {
|
|
|
458
601
|
|| assignment.authorityMode !== "CLIENT_MANAGED") {
|
|
459
602
|
throw new CodexServiceError("The local v4 lineage no longer matches the server assignment.", "lineage_version_conflict", 409);
|
|
460
603
|
}
|
|
604
|
+
assertCodexSubscriptionActive(assignment, assignment.label);
|
|
461
605
|
const selection = await saveCodexSelection(deps.config.persistence.configDirectory, {
|
|
462
606
|
serviceUrl: options.server ?? deps.config.connection.serverUrl,
|
|
463
607
|
allowInsecurePoc: Boolean(options.insecurePoc || options.insecureHttp || deps.config.connection.allowInsecureHttp),
|
|
@@ -502,6 +646,7 @@ async function selectedCredentials(deps, requested, options = {}) {
|
|
|
502
646
|
if (!assignment) {
|
|
503
647
|
throw new CodexServiceError("The selected schema-v2 credential assignment is no longer active.", "codex_assignment_not_found", 404);
|
|
504
648
|
}
|
|
649
|
+
assertCodexSubscriptionActive(assignment, assignment.label);
|
|
505
650
|
const state = await readManagedCodexLineageState(deps.env);
|
|
506
651
|
const journal = await readCodexShortLivedDeliveryJournal(deps.env);
|
|
507
652
|
const stateMatches = state !== undefined
|
|
@@ -528,6 +673,23 @@ async function selectedCredentials(deps, requested, options = {}) {
|
|
|
528
673
|
if (!selection.account.accountId) {
|
|
529
674
|
throw new CodexServiceError("The saved Codex selection has no provider account.", "invalid_configuration");
|
|
530
675
|
}
|
|
676
|
+
const client = deps.clientFor({
|
|
677
|
+
serverUrl: selection.serviceUrl,
|
|
678
|
+
allowInsecureHttp: selection.allowInsecurePoc
|
|
679
|
+
});
|
|
680
|
+
const accounts = await client.listAccounts();
|
|
681
|
+
const selectedAccount = accounts.accounts.find((account) => account.id === selection.account.id
|
|
682
|
+
|| account.accountId === selection.account.accountId);
|
|
683
|
+
if (!selectedAccount) {
|
|
684
|
+
throw new CodexServiceError("The selected Codex account is no longer available.", "codex_account_not_found", 404);
|
|
685
|
+
}
|
|
686
|
+
assertCodexSubscriptionActive(selectedAccount, selectedAccount.label ?? selectedAccount.id);
|
|
687
|
+
if (!isCodexAccountAvailable(selectedAccount)) {
|
|
688
|
+
throw new CodexServiceError(`ChatGPT account ${selectedAccount.label ?? selectedAccount.id} is unavailable.`, selectedAccount.credentialStatus === "NEEDS_REAUTH"
|
|
689
|
+
? "codex_account_needs_reauth"
|
|
690
|
+
: selectedAccount.credentialStatus === "EXPIRED"
|
|
691
|
+
? "codex_account_expired" : "codex_account_temporarily_unavailable", 409);
|
|
692
|
+
}
|
|
531
693
|
if (!(await codexFileCredentialsMatchAccount(selection.account.accountId, deps.env))) {
|
|
532
694
|
return installCredentials(deps, selection, {
|
|
533
695
|
serverUrl: selection.serviceUrl,
|
|
@@ -538,6 +700,24 @@ async function selectedCredentials(deps, requested, options = {}) {
|
|
|
538
700
|
return { selection, expiresAt: local.expiresAt };
|
|
539
701
|
}
|
|
540
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
|
+
}
|
|
541
721
|
if (multiDeviceCredentialSyncEnabled(deps.config)) {
|
|
542
722
|
const client = deps.clientFor({
|
|
543
723
|
serverUrl: selected.selection.serviceUrl,
|
|
@@ -555,6 +735,7 @@ async function launchWithSelectedCredentials(deps, selected, launch) {
|
|
|
555
735
|
&& item.authorityMode === "CLIENT_MANAGED");
|
|
556
736
|
if (!assignment)
|
|
557
737
|
throw new CodexServiceError("The selected assignment is not ready for a fenced v5 device session.", "client_credential_assignment_not_found", 404);
|
|
738
|
+
assertCodexSubscriptionActive(assignment, assignment.label);
|
|
558
739
|
return superviseCodexV5Credentials({
|
|
559
740
|
client,
|
|
560
741
|
tenantId: deps.config.connection.tenantId,
|
|
@@ -622,18 +803,25 @@ export function registerCodexCommands(program, deps, options = {}) {
|
|
|
622
803
|
else
|
|
623
804
|
deps.output.write("Logged out.\n");
|
|
624
805
|
});
|
|
625
|
-
if (!includeAuthCommands && accountTokenPoolV4Enabled(deps.config))
|
|
626
|
-
serviceOptions(codex.command("login")
|
|
627
|
-
.description(
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
.option("--
|
|
631
|
-
|
|
632
|
-
|
|
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) => {
|
|
633
819
|
if (!accountTokenPoolV4Enabled(deps.config)) {
|
|
634
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);
|
|
635
821
|
}
|
|
636
|
-
const target =
|
|
822
|
+
const target = requestBoundDeviceCredentialsEnabled(deps.config)
|
|
823
|
+
? { mode: "UNBOUND_POOL" }
|
|
824
|
+
: resolveManagedCodexV3AssignmentTarget(opts);
|
|
637
825
|
const result = await executeCodexTokenPoolV4Login({
|
|
638
826
|
client: deps.clientFor(serviceConnection(opts)),
|
|
639
827
|
tenantId: deps.config.connection.tenantId,
|
|
@@ -660,8 +848,131 @@ export function registerCodexCommands(program, deps, options = {}) {
|
|
|
660
848
|
else
|
|
661
849
|
deps.output.write(`Codex credential uploaded to token pool; status=${result.status}, placement=${result.placement}.\n`);
|
|
662
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
|
+
});
|
|
663
952
|
serviceOptions(codex.command("list").description("List available Codex accounts without downloading credentials"))
|
|
664
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
|
+
}
|
|
665
976
|
const { response: result, selectedAccountId, assignments, v3Assignments } = await loadAccountData(deps, opts);
|
|
666
977
|
const hasAvailableAccount = result.accounts.some(isCodexAccountAvailable);
|
|
667
978
|
const shouldRequestAccount = result.accounts.length === 0
|
|
@@ -684,6 +995,21 @@ export function registerCodexCommands(program, deps, options = {}) {
|
|
|
684
995
|
serviceOptions(codex.command("use [account]").description("Select an account and update the official Codex auth.json"))
|
|
685
996
|
.option("--takeover", "explicitly replace a v4 credential active on another host")
|
|
686
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
|
+
}
|
|
687
1013
|
const v4 = accountTokenPoolV4Enabled(deps.config);
|
|
688
1014
|
if (opts.takeover && !v4)
|
|
689
1015
|
throw new CodexServiceError("Credential takeover requires the v4 token-pool capability.", "codex_token_pool_v4_not_enabled", 409);
|
|
@@ -770,6 +1096,7 @@ export function registerCodexCommands(program, deps, options = {}) {
|
|
|
770
1096
|
if (schemaV2LifecycleEnabled(deps.config))
|
|
771
1097
|
await discardCodexFileCredentialBackup(deps.env);
|
|
772
1098
|
const auth = await resetManagedCodexFileCredentials(selection?.account.providerAccountId ?? selection?.account.accountId, deps.env);
|
|
1099
|
+
await resetCodexV6LocalState(deps.env);
|
|
773
1100
|
await clearCodexSelection(deps.config.persistence.configDirectory);
|
|
774
1101
|
if (await json(command, deps))
|
|
775
1102
|
print(deps.output, success({ reset: true, auth_action: auth.action, legacy_provider_removed: cleanup.changed }));
|
|
@@ -797,6 +1124,40 @@ export function registerCodexCommands(program, deps, options = {}) {
|
|
|
797
1124
|
throw new Error("auth-agent replaces the current login; add --replace-login to confirm.");
|
|
798
1125
|
if (opts.credentialStore !== "file")
|
|
799
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
|
+
}
|
|
800
1161
|
if (accountTokenPoolV4Enabled(deps.config)) {
|
|
801
1162
|
const interval = Number(opts.interval) * 1_000;
|
|
802
1163
|
if (!Number.isFinite(interval) || interval <= 0)
|
|
@@ -1057,6 +1418,166 @@ export function registerCodexCommands(program, deps, options = {}) {
|
|
|
1057
1418
|
}
|
|
1058
1419
|
const admin = codex.command("admin")
|
|
1059
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
|
+
});
|
|
1060
1581
|
const adminAccounts = admin.command("accounts")
|
|
1061
1582
|
.description("Inspect accounts, credential history, and refresh state");
|
|
1062
1583
|
serviceOptions(adminAccounts.command("list").description("List all tenant Codex accounts"))
|
|
@@ -1071,8 +1592,11 @@ export function registerCodexCommands(program, deps, options = {}) {
|
|
|
1071
1592
|
return;
|
|
1072
1593
|
}
|
|
1073
1594
|
for (const account of result.accounts) {
|
|
1074
|
-
|
|
1595
|
+
const expired = isCodexSubscriptionExpired(account);
|
|
1596
|
+
deps.output.write(`${formatAdminAccount(account)} ${expired ? "EXPIRED" : account.status}/${account.credentialStatus}`
|
|
1075
1597
|
+ ` assignments=${account.activeAssignmentCount ?? 0}`
|
|
1598
|
+
+ `${account.subscriptionExpiresAt
|
|
1599
|
+
? ` subscription-expires=${new Date(account.subscriptionExpiresAt).toISOString()}` : ""}`
|
|
1076
1600
|
+ `${account.lastRefreshErrorCode ? ` error=${account.lastRefreshErrorCode}` : ""}\n`);
|
|
1077
1601
|
}
|
|
1078
1602
|
});
|
|
@@ -1114,6 +1638,36 @@ export function registerCodexCommands(program, deps, options = {}) {
|
|
|
1114
1638
|
if (!ok)
|
|
1115
1639
|
process.exitCode = 1;
|
|
1116
1640
|
});
|
|
1641
|
+
serviceOptions(adminAccounts.command("renew <account>")
|
|
1642
|
+
.description("Renew an expired account by recording a later subscription expiry")
|
|
1643
|
+
.requiredOption("--expires-at <timestamp>", "new ISO-8601 subscription expiry with timezone")
|
|
1644
|
+
.option("--idempotency-key <key>", "reuse a prior account-renewal idempotency key"))
|
|
1645
|
+
.action(async (account, opts, command) => {
|
|
1646
|
+
const expiresAt = parseSubscriptionRenewalExpiry(opts.expiresAt);
|
|
1647
|
+
const now = Date.now();
|
|
1648
|
+
if (expiresAt <= now) {
|
|
1649
|
+
throw new CodexServiceError("The renewed subscription expiry must be in the future.", "invalid_codex_account_renewal");
|
|
1650
|
+
}
|
|
1651
|
+
const client = deps.clientFor(serviceConnection(opts));
|
|
1652
|
+
const resolved = await resolveAdminAccount(client, account);
|
|
1653
|
+
if (resolved.subscriptionExpiresAt != null && expiresAt <= resolved.subscriptionExpiresAt) {
|
|
1654
|
+
throw new CodexServiceError("The renewed subscription expiry must be later than the current expiry.", "invalid_codex_account_renewal", 409);
|
|
1655
|
+
}
|
|
1656
|
+
const result = await client.renewAdminAccount(resolved.id, {
|
|
1657
|
+
schemaVersion: CODEX_DISTRIBUTION_SCHEMA_VERSION,
|
|
1658
|
+
expectedSubscriptionExpiresAt: resolved.subscriptionExpiresAt,
|
|
1659
|
+
subscriptionExpiresAt: expiresAt
|
|
1660
|
+
}, opts.idempotencyKey?.trim() || randomUUID());
|
|
1661
|
+
if (await json(command, deps)) {
|
|
1662
|
+
print(deps.output, success(result));
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
deps.output.write(`${formatAdminAccount(result.account)}: subscription renewed`
|
|
1666
|
+
+ ` previous=${result.previousSubscriptionExpiresAt
|
|
1667
|
+
? new Date(result.previousSubscriptionExpiresAt).toISOString() : "none"}`
|
|
1668
|
+
+ ` expires=${new Date(result.account.subscriptionExpiresAt).toISOString()}`
|
|
1669
|
+
+ ` status=${result.account.status}/${result.account.credentialStatus}\n`);
|
|
1670
|
+
});
|
|
1117
1671
|
const adminAssignments = admin.command("assignments")
|
|
1118
1672
|
.description("List, bind, or unbind tenant Codex account assignments");
|
|
1119
1673
|
serviceOptions(adminAssignments.command("list").description("List tenant Codex account assignments"))
|