osdy-pi 1.11.11 → 1.11.12
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 +21 -5
- package/extensions/osdy-pi/codex-reset-store.test.ts +236 -0
- package/extensions/osdy-pi/codex-reset-store.ts +174 -0
- package/extensions/osdy-pi/codex-reset-ui.test.ts +70 -0
- package/extensions/osdy-pi/codex-reset-ui.ts +52 -0
- package/extensions/osdy-pi/codex-reset.test.ts +525 -0
- package/extensions/osdy-pi/codex-reset.ts +213 -0
- package/extensions/osdy-pi/codex-usage-ui.test.ts +132 -2
- package/extensions/osdy-pi/codex-usage-ui.ts +75 -30
- package/extensions/osdy-pi/codex-usage.test.ts +370 -1
- package/extensions/osdy-pi/codex-usage.ts +198 -19
- package/extensions/osdy-pi/control-center-usage.test.ts +96 -3
- package/extensions/osdy-pi/control-center-usage.ts +15 -4
- package/extensions/osdy-pi/control-center.test.ts +28 -2
- package/extensions/osdy-pi/control-center.ts +14 -2
- package/extensions/osdy-pi/profile-codex-usage.test.ts +34 -0
- package/extensions/osdy-pi/runtime.test.ts +289 -0
- package/extensions/osdy-pi/runtime.ts +98 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -8,6 +8,8 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
|
|
|
8
8
|
|
|
9
9
|
**The Control Center consolidates Osdy controls in `/osdyConfig`**: ten categories cover appearance, Git, sounds, accounts, usage, and TODO/Agents provider settings. Account quota previews query a selected stored Codex profile without switching it. The header shows the installed Osdy package version when it fits; constrained layouts show only the centered mascot. See [Control Center in Pi](#control-center-in-pi) for confirmation and persistence boundaries.
|
|
10
10
|
|
|
11
|
+
**Codex banked resets show counts and per-reset local expiry** in `/usage` and the Control Center. Press **u** in `/usage` or select **Usage → Use or check banked reset** for Cancel-first, active-account use or pending-attempt checks; stored-profile previews stay read-only. See [Use or check a banked reset](#use-or-check-a-banked-reset).
|
|
12
|
+
|
|
11
13
|
**1.11.0 adds three Spider-Man-inspired dark palettes**, bringing the bundle to **23 themes**. It retains the four-page local usage analytics introduced in 1.10.0, with shared period and profile/provider/model filters, keyboard navigation, and metadata-only history. These version introductions are historical, not a fixed latest-version claim. After npm Trusted Publishing is configured, pushes to main (including direct pushes) run checks and attempt to publish an automatically allocated patch to npm. Failed checks publish nothing; authentication or registry failures can also stop publication. Verify the resolved npm version before installing. Maintainers: [automatic npm publishing setup](https://github.com/OsdyOrtiz/Osdy-Pi/blob/main/docs/npm-publishing.md).
|
|
12
14
|
|
|
13
15
|
| Area | What ships |
|
|
@@ -70,6 +72,10 @@ On macOS, **Option = Alt** in Pi's shortcut names: these bindings register as `c
|
|
|
70
72
|
|
|
71
73
|
Ctrl+Option+U opens the existing `/usage` dashboard with the same refresh behavior; it does not open `/osdy-usage`.
|
|
72
74
|
|
|
75
|
+
When Codex reports an available banked reset count, `/usage`, **Account → View usage**, and the **Usage** category show **Banked resets: N**, including zero. Unknown counts are omitted. Banked resets are separate from paid **Credits** and legacy **Credit resets**; local analytics filters do not change the count. Displaying it never consumes a reset or changes the compact footer.
|
|
76
|
+
|
|
77
|
+
Positive counts also trigger a best-effort read-only reset-details GET within the same timeout budget. Dashboards label listed resets **Reset 1:**, **Reset 2:**, and so on, with expiry date/time in your machine's **local timezone**; the Usage count row includes these lines in its expanded help (`?`). Missing or invalid expiry shows **Expiry not provided**. Past dates show **expired** with **(cached; local time)** and are excluded from new-reset selection. **Reset expiry details unavailable** or **Partial expiry details: M of N resets listed** identifies missing or incomplete details. The usage summary remains the count authority even when details are empty, capped, inconsistent, or fail. Quotas and the count remain visible on details failure.
|
|
78
|
+
|
|
73
79
|
- Use **↑/↓**, **Home/End**, or **Page Up/Down** to navigate; **Tab** or **←/→** switches between categories and details. **Enter** selects. Single blank lines separate the heading, summary, compact options, selected-item help, status, and action hints—not individual options. Gaps yield on short viewports so the selected option and controls remain reachable. Press **?** to expand/collapse the selected help and view notes; resize if expanded help is clipped. Confirmation effects remain complete, and **?** never toggles help during confirmation, path editing, loading, querying, or saving.
|
|
74
80
|
- TODO shows configured selection, current eligibility, and actually loaded SDK registration separately. Opt-in/out requires an idle session and a Cancel-first inline confirmation naming the target, owned Gentle filter effects, and reload. Successful selection closes/disposes the panel and releases its editor hold before automatic command reload or the Ctrl+Option+O manual-reload notice described above; restart Pi if reload fails. Rejections stay in the panel. No installs or task-history changes occur; legacy `/osdy-pi todo on|off|status` remains available.
|
|
75
81
|
- Agents shows concise **Joker / Gentle** choices without activating anything. With detail focus, visible lowercase-only **j Joker / g Gentle** letters open the existing confirmation directly; only available, unambiguous rows get shortcuts. **↑/↓ + Enter** remains the fallback. Profile and Agent selections survive category navigation; unavailable choices fall back to the current row. Letters never bypass category focus, confirmation, path input, loading, querying, or saving. Selection is idle-only and Cancel-first confirmed, naming the real target, possible Joker installation, owned agent filters, and preserved unrelated resources. Normal personal Pi only: `pi:dev` and isolated/override sessions are blocked, never redirected to personal settings. Success closes/disposes the panel and releases its editor hold before automatic command reload or the Ctrl+Option+O manual-reload notice described above; restart Pi if reload fails. Legacy `/osdy-pi agents setup|on|off|status` is unchanged.
|
|
@@ -81,7 +87,7 @@ Ctrl+Option+U opens the existing `/usage` dashboard with the same refresh behavi
|
|
|
81
87
|
- **Sounds** shows each event's saved and effective path/source. Configure opens a single-line path editor **inside this overlay**: enter a readable `.mp3`/`.wav` path, then **Enter** to validate and save immediately, or **Esc** to cancel editing. Relative paths resolve against the project directory and save as absolute paths. Clear removes only that event's saved path from the existing v1 audio store (`<agent-dir>/extensions/osdy-pi/audio-notifications.json`). Notifications read this store afresh; refresh failures are reported separately from save failures.
|
|
82
88
|
- Sounds' master enablement is **unavailable**: the existing schema has paths, not an enable switch or presets. Startup `--osdy-pi-sound-<event>` flags override saved paths, so **clearing a saved path does not disable flag-configured playback**. Only **Test effective** explicitly plays a sound; opening categories and configuring paths never play. Tests report unavailable files/platforms or playback failures; playback remains macOS/Windows only. The legacy `/osdy-pi sound setup` wizard is preserved.
|
|
83
89
|
- **Account** shows one row per validated profile, marked **Active** and **Default** where applicable. Select a profile, then use its visible context bar: **v View usage**, **s Switch** (inactive profiles), **d Set default**, and **c Clear default** (when a default exists). **v** or **Enter** explicitly queries that profile's stored Codex credentials without activating it; no second action list appears. Opening, navigating or highlighting never queries; stored profiles are never polled. A dedicated quota view reuses `/usage` dashboard content: Session/Weekly cards, additional bucket groups, remaining-capacity bars, resets, and the quotas/account summary with plan and credits when available. Identity belongs to the selected stored profile; unknown provider/model metadata is labeled honestly. Checked and updated times use local time; absent data is unknown, not zero. Refresh/Back controls and query feedback stay pinned while long dashboard content scrolls. **r Refresh** repeats the selected profile query; **s Switch**, when available, confirms activation of the currently viewed inactive profile. Cancelling that confirmation preserves the quota view and scroll position. **b Back** or **Esc** returns to the overview with the profile selected, without closing the overlay. In quota details, **↑/↓** selects Refresh/Back and **Enter** activates it; **Page Up/Down** and **Home/End** scroll long quota data. Switch, set default, and clear default require an **in-screen confirmation** with Cancel selected initially; **Esc** cancels confirmation without closing the overlay. Single blank separators group the confirmation title, complete effects, choices, and instructions; gaps yield on short viewports. Resize if the full confirmation cannot fit—acceptance stays hidden and blocked. Switching through `/osdyConfig` waits for Pi to become idle; Ctrl+Option+O refuses busy account changes. Successful switching activates through the existing backend and refreshes subscription usage. Login remains Pi-owned (`/login`); create/rename/remove remain available in `/osdy-account`. Category navigation never changes profiles or credentials.
|
|
84
|
-
- **Usage** shows the runtime's cached active Codex subscription quota (remaining-capacity bars and percentages, window, reset/fetch times) and the existing local history owner's recorded token/cache totals, cost gaps, and model detail. Select **Range: day/week/month** for the current local calendar period; toggle **Accounts: all/current** for local analytics (unmanaged when no valid current profile exists). Quota always belongs to the active account, not the analytics filter; local token/cost totals never become quota percentages. Select **Refresh quota and local history** explicitly; rendering and filters never fetch. Empty, loading, unavailable, and limited/incomplete coverage are reported honestly. Estimates are not billing or subscription quota. `/usage`
|
|
90
|
+
- **Usage** shows the runtime's cached active Codex subscription quota (remaining-capacity bars and percentages, window, reset/fetch times) and the existing local history owner's recorded token/cache totals, cost gaps, and model detail. Select **Range: day/week/month** for the current local calendar period; toggle **Accounts: all/current** for local analytics (unmanaged when no valid current profile exists). Quota always belongs to the active account, not the analytics filter; local token/cost totals never become quota percentages. Select **Refresh quota and local history** explicitly; rendering and filters never fetch. Empty, loading, unavailable, and limited/incomplete coverage are reported honestly. Estimates are not billing or subscription quota. `/usage` remains the subscription dashboard; `/osdy-usage` remains local analytics.
|
|
85
91
|
- **Esc** closes without undoing live or saved changes, except when cancelling a confirmation/input or returning from Account quota details. Account read-only queries are cancelled on Back/Esc, category exit or close; late results are ignored even if cancellation is not honored. Other in-flight operations still finish and are not claimed cancelled. Late results cannot replace a closed overlay or another category. On narrow terminals, categories and details share the viewport; use Tab to switch.
|
|
86
92
|
|
|
87
93
|
This modal requires Pi's terminal UI, not RPC, JSON, or print mode. `/osdy-pi` remains the legacy extension command; the external shell command `osdy` (`bin/osdy.mjs`) remains the isolated launcher described below.
|
|
@@ -229,7 +235,7 @@ For a project-local installation, run `pi remove -l npm:@juicesharp/rpiv-todo` i
|
|
|
229
235
|
|
|
230
236
|
**Quick path:** `/osdyConfig` → **Account** → select a profile → **v** (or **Enter**) for quota bars. Use **r Refresh** to query again or **b Back** / **Esc** to return with the profile selected. From the overview or quota dashboard, **s Switch** opens the same Cancel-first confirmation for the eligible inactive profile; cancelling from quota preserves the view and scroll position. From the overview, **d** sets the default and **c** clears an existing default, also with Cancel-first confirmation. The active profile also supports explicit stored-credential queries.
|
|
231
237
|
|
|
232
|
-
Preview is **Codex only**, a live query using the selected profile's **stored credentials snapshot**, with no automatic refresh or stored-profile polling. Only **View usage** and the quota view's **Refresh** query; profile navigation does not. It never activates, borrows active credentials, refreshes tokens, writes auth, changes models/session state or overwrites active quota. Missing, malformed, expired or remotely rejected snapshots are unavailable—not zero quota. Even the current profile's stored snapshot may be stale: log in and save through the normal account workflow outside preview. Profile labels and decoded JWT claims do not verify human identity or server validity. Local history remains token/cost estimates, not subscription quota. Legacy `/osdy-account`, `/usage` and `/osdy-usage` remain
|
|
238
|
+
Preview is **Codex only**, a live query using the selected profile's **stored credentials snapshot**, with no automatic refresh or stored-profile polling. Only **View usage** and the quota view's **Refresh** query; profile navigation does not. It never activates, borrows active credentials, refreshes tokens, writes auth, changes models/session state or overwrites active quota. Missing, malformed, expired or remotely rejected snapshots are unavailable—not zero quota. Even the current profile's stored snapshot may be stale: log in and save through the normal account workflow outside preview. Profile labels and decoded JWT claims do not verify human identity or server validity. Local history remains token/cost estimates, not subscription quota. Legacy `/osdy-account`, `/usage` and `/osdy-usage` remain available; Agents configuration remains normal personal Pi only.
|
|
233
239
|
|
|
234
240
|
Osdy Pi can keep multiple ChatGPT Plus/Pro accounts authenticated and let you choose which one starts Pi. `personal` and `work` are only examples—you can create as many named profiles as you need.
|
|
235
241
|
|
|
@@ -294,7 +300,7 @@ To resume a specific session directly from the terminal:
|
|
|
294
300
|
osdy-pi account use work -- --session /absolute/path/to/session.jsonl
|
|
295
301
|
```
|
|
296
302
|
|
|
297
|
-
> **Privacy:** each profile keeps a private `auth.json`; Pi runs against its canonical shared `auth.json`. Account activation/preservation atomically copies opaque auth files. The separate explicit Account **View usage / Refresh** query reads a bounded stored auth snapshot, parses its Codex OAuth fields and unverified JWT claims, and sends the access token/account ID only to the fixed HTTPS Codex usage
|
|
303
|
+
> **Privacy:** each profile keeps a private `auth.json`; Pi runs against its canonical shared `auth.json`. Account activation/preservation atomically copies opaque auth files. The separate explicit Account **View usage / Refresh** query reads a bounded stored auth snapshot, parses its Codex OAuth fields and unverified JWT claims, and sends the access token/account ID only to the fixed HTTPS Codex usage and optional reset-details endpoints. It never refreshes tokens, logs in, switches accounts, writes auth, or queries automatically; it does not print or log credentials. Session history, settings, installed packages, and extension resources are shared, so every profile can access that local state. It keeps Pi's canonical `openai-codex` provider and delegates authentication to Pi's built-in `/login` flow.
|
|
298
304
|
|
|
299
305
|
Account switching is serialized in-process and protected by a private cross-process lock. Osdy Pi accepts only bounded regular auth files, refuses symlink-based auth sources, writes replacements with private permissions, and restores the previous canonical auth and account metadata when activation fails. A separate namespace lock protects concurrent profile creation, rename, and removal. Launchers activate the selected auth before starting Pi while continuing to use the shared agent directory; existing managed-directory links from earlier profile layouts are resolved for compatibility.
|
|
300
306
|
|
|
@@ -446,7 +452,17 @@ Active quota loads when an enabled session starts and refreshes when **Ctrl+Opti
|
|
|
446
452
|
|
|
447
453
|
Automatic refresh retains the last snapshot during loading and on error, so an older reading can remain visible until a successful query replaces it. Manual refresh clears the previous snapshot before authentication resolves and supersedes any in-flight automatic request. Account switching refreshes active quota through that same clearing path. Session replacement and shutdown stop the timer, cancel pending requests and clear quota state; late results are ignored.
|
|
448
454
|
|
|
449
|
-
> **Privacy:** this active-account `/usage` path resolves OAuth through Pi's `modelRegistry`, rather than directly reading auth files. The separate Control Center Account **View usage / Refresh** path reads the selected profile's bounded stored credentials snapshot; it never borrows active credentials or refreshes tokens, logs in, switches accounts, writes auth, or queries automatically. Both paths transmit the access token/account ID only to the fixed HTTPS Codex usage
|
|
455
|
+
> **Privacy:** this active-account `/usage` path resolves OAuth through Pi's `modelRegistry`, rather than directly reading auth files. The separate Control Center Account **View usage / Refresh** path reads the selected profile's bounded stored credentials snapshot; it never borrows active credentials or refreshes tokens, logs in, switches accounts, writes auth, or queries automatically. Both paths transmit the access token/account ID only to the fixed HTTPS Codex usage and optional reset-details endpoints, reject redirects, and bound timeout and response size. Quota previews do not persist or log credentials or display account IDs, tokens, raw response bodies, or endpoint internals. Only an explicitly selected and confirmed active-account reset additionally POSTs to the fixed Codex reset-consume endpoint.
|
|
456
|
+
|
|
457
|
+
#### Use or check a banked reset
|
|
458
|
+
|
|
459
|
+
1. In `/usage`, press **u**; or select **Usage → Use or check banked reset** in `/osdyConfig`. Account → View usage remains read-only, including previews of the current profile.
|
|
460
|
+
2. The overlay closes first. Choose an indexed reset with its **local expiry date/time** (or **Expiry not provided**), then explicitly select **Use reset**. **Cancel is first**; Cancel/Esc sends nothing. The confirmation affects only the **active Codex account** and explains that a successful reset consumes one banked reset. Auth/account/session are checked again before POST.
|
|
461
|
+
3. `/usage` reopens with the result snapshot if the same context remains active; Control Center exits, so reopen Usage to inspect it. A failed quota refresh or local bookkeeping failure is reported separately from a confirmed server outcome. `no_credit` and `nothing_to_reset` do not consume a reset; `already_redeemed` confirms prior success.
|
|
462
|
+
|
|
463
|
+
**Unknown outcome:** do not start another reset or infer success from a changed count. Use **u** or the Usage row again and explicitly choose **Check pending attempt** to recheck the **same stored request UUID and reset ID**, which may finish an earlier unconfirmed reset—not start a new attempt. A private, durable local pending journal stores attempt identifiers, never credentials; reload does not automatically retry or replay it. Checking remains available with active Codex authentication even when the displayed count is zero or quota is unavailable.
|
|
464
|
+
|
|
465
|
+
**Blocked storage/lock:** stop for manual verification of the previous attempt and any other running Pi process. Lock acquisition or pre-dispatch journal-save failure prevents a reset POST. Locks are not automatically reclaimed, even when apparently stale; orphaned locks can require manual recovery. There is no automatic lock cleanup or guarantee of perfect crash recovery. This flow has mocked transport tests, not live API, real-reset, or manual TUI verification.
|
|
450
466
|
|
|
451
467
|
### Local usage analytics (starting in 1.10.0)
|
|
452
468
|
|
|
@@ -461,7 +477,7 @@ Run `/osdy-usage` in interactive terminal Pi. **Summary** opens by default; use
|
|
|
461
477
|
|
|
462
478
|
Narrow terminals stack metrics and wrap names; every group remains reachable by scrolling. Shares use the selected total tokens; bars scale to each ranking's maximum. Loading, stale, scan-limit/warning and unpriced/partial-cost states remain on every page and in help. Sticky status lines wrap instead of concealing cost status behind scan warnings, including at End. When height is scarce, statuses take priority over optional headings, charts and footer controls; the active page remains visible when space permits. Press `?` for methodology and full controls, then `?` again to return. Choosing a page also leaves help without discarding filters.
|
|
463
479
|
|
|
464
|
-
This is separate from the
|
|
480
|
+
This is separate from the `/usage` subscription quota panel. RPC, JSON and print modes receive a short notification instead of charts. The four-page UI passed independent scripted synthetic PTY checks for navigation, shared selection and responsive statuses at 12/32/100 columns, including a five-row terminal. Human visual confirmation and provider-backed capture remain unverified.
|
|
465
481
|
|
|
466
482
|
| Control | Action |
|
|
467
483
|
| --- | --- |
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { chmod, mkdtemp, open, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import type { FileHandle } from "node:fs/promises";
|
|
6
|
+
import { registerHooks } from "node:module";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
registerHooks({
|
|
13
|
+
resolve(specifier, context, nextResolve) {
|
|
14
|
+
if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
|
|
15
|
+
const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
|
|
16
|
+
if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
|
|
17
|
+
}
|
|
18
|
+
return nextResolve(specifier, context);
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const { createCodexResetStore } = await import("./codex-reset-store.js");
|
|
23
|
+
const accountId = "synthetic-account/../not-a-path";
|
|
24
|
+
const pending = {
|
|
25
|
+
version: 1 as const, accountId, creditId: "synthetic-credit",
|
|
26
|
+
requestId: "11111111-1111-4111-8111-111111111111", expiresAt: 2_000, status: "pending" as const,
|
|
27
|
+
};
|
|
28
|
+
const basename = createHash("sha256").update(accountId).digest("hex");
|
|
29
|
+
|
|
30
|
+
async function fixture(t: test.TestContext) {
|
|
31
|
+
const directory = await mkdtemp(join(tmpdir(), "osdy-reset-test-"));
|
|
32
|
+
t.after(() => rm(directory, { recursive: true, force: true })); // Only this synthetic fixture.
|
|
33
|
+
const root = join(directory, "journal");
|
|
34
|
+
return { root, store: createCodexResetStore(root) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
void test("private hashed journals round-trip and resolved attempts permit a new request", async (t) => {
|
|
38
|
+
const { root, store } = await fixture(t);
|
|
39
|
+
const lease = await store.acquire(accountId);
|
|
40
|
+
assert.equal(await lease.read(), undefined);
|
|
41
|
+
await lease.write(pending);
|
|
42
|
+
assert.deepEqual(await lease.read(), pending);
|
|
43
|
+
assert.equal((await stat(root)).mode & 0o777, 0o700);
|
|
44
|
+
for (const name of await readdir(root)) {
|
|
45
|
+
assert.match(name, /^[a-f0-9]{64}\.(json|lock)$/);
|
|
46
|
+
assert.equal((await stat(join(root, name))).mode & 0o777, 0o600);
|
|
47
|
+
}
|
|
48
|
+
assert.equal((await readFile(join(root, `${basename}.json`), "utf8")).includes("accessToken"), false);
|
|
49
|
+
await lease.write({ ...pending, status: "resolved", confirmedCode: "reset" });
|
|
50
|
+
await lease.write({ ...pending, requestId: "22222222-2222-4222-8222-222222222222" });
|
|
51
|
+
await lease.release();
|
|
52
|
+
const restored = await store.acquire(accountId);
|
|
53
|
+
assert.equal((await restored.read())?.requestId, "22222222-2222-4222-8222-222222222222");
|
|
54
|
+
await restored.release();
|
|
55
|
+
assert.deepEqual(await readdir(root), [`${basename}.json`]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
void test("account leases are exclusive, isolate accounts, and never reclaim existing stale locks", async (t) => {
|
|
59
|
+
const { root, store } = await fixture(t);
|
|
60
|
+
const lease = await store.acquire(accountId);
|
|
61
|
+
await assert.rejects(store.acquire(accountId), /^Error: Codex reset journal is unavailable$/);
|
|
62
|
+
const other = await store.acquire("synthetic-other");
|
|
63
|
+
assert.equal(await other.read(), undefined);
|
|
64
|
+
await lease.write(pending);
|
|
65
|
+
assert.equal(await other.read(), undefined);
|
|
66
|
+
await other.release();
|
|
67
|
+
await lease.release();
|
|
68
|
+
await writeFile(join(root, `${basename}.lock`), "", { mode: 0o600 });
|
|
69
|
+
await assert.rejects(store.acquire(accountId), /^Error: Codex reset journal is unavailable$/);
|
|
70
|
+
assert.equal(await readFile(join(root, `${basename}.lock`), "utf8"), "");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
for (const content of [
|
|
74
|
+
"{broken", "x".repeat(8_193), JSON.stringify({ ...pending, version: 2 }),
|
|
75
|
+
JSON.stringify({ ...pending, accountId: "wrong-account" }), JSON.stringify({ ...pending, requestId: "bad" }),
|
|
76
|
+
JSON.stringify({ ...pending, expiresAt: -1 }), JSON.stringify({ ...pending, accessToken: "forbidden" }),
|
|
77
|
+
JSON.stringify({ ...pending, status: "resolved", confirmedCode: "invented" }),
|
|
78
|
+
JSON.stringify({ ...pending, status: "pending", confirmedCode: "reset" }),
|
|
79
|
+
]) {
|
|
80
|
+
void test(`rejects corrupt or unbounded journal fixture ${content.length}:${content.slice(0, 12)}`, async (t) => {
|
|
81
|
+
const { root, store } = await fixture(t);
|
|
82
|
+
const lease = await store.acquire(accountId);
|
|
83
|
+
await writeFile(join(root, `${basename}.json`), content, { mode: 0o600 });
|
|
84
|
+
await assert.rejects(lease.read(), /^Error: Codex reset journal is unavailable$/);
|
|
85
|
+
await lease.release();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
void test("refuses final-file symlinks on both read and atomic write, preserving the target", async (t) => {
|
|
90
|
+
const { root, store } = await fixture(t);
|
|
91
|
+
const lease = await store.acquire(accountId);
|
|
92
|
+
const target = join(root, "synthetic-target");
|
|
93
|
+
await writeFile(target, "untouched", { mode: 0o600 });
|
|
94
|
+
await symlink(target, join(root, `${basename}.json`));
|
|
95
|
+
await assert.rejects(lease.read());
|
|
96
|
+
await assert.rejects(lease.write(pending));
|
|
97
|
+
assert.equal(await readFile(target, "utf8"), "untouched");
|
|
98
|
+
await lease.release();
|
|
99
|
+
assert.equal((await readdir(root)).some((name) => name.endsWith(".tmp")), false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
void test("refuses symlink roots and non-private journal files or directories", async (t) => {
|
|
103
|
+
const { root, store } = await fixture(t);
|
|
104
|
+
const lease = await store.acquire(accountId);
|
|
105
|
+
await lease.write(pending);
|
|
106
|
+
await chmod(join(root, `${basename}.json`), 0o644);
|
|
107
|
+
await assert.rejects(lease.read());
|
|
108
|
+
await lease.release();
|
|
109
|
+
await chmod(root, 0o755);
|
|
110
|
+
await assert.rejects(store.acquire(accountId));
|
|
111
|
+
const link = `${root}-link`;
|
|
112
|
+
await symlink(root, link);
|
|
113
|
+
await assert.rejects(createCodexResetStore(link).acquire(accountId));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
void test("validation/write failure cannot replace pending; terminal outcomes must match the logical attempt", async (t) => {
|
|
117
|
+
const { root, store } = await fixture(t);
|
|
118
|
+
const lease = await store.acquire(accountId);
|
|
119
|
+
await lease.write(pending);
|
|
120
|
+
await assert.rejects(lease.write({ ...pending, creditId: "different" }));
|
|
121
|
+
await assert.rejects(lease.write({ ...pending, status: "resolved", requestId: "22222222-2222-4222-8222-222222222222" }));
|
|
122
|
+
await assert.rejects(lease.write({ ...pending, creditId: "x".repeat(8_193) }));
|
|
123
|
+
assert.deepEqual(await lease.read(), pending);
|
|
124
|
+
assert.equal((await readdir(root)).some((name) => name.endsWith(".tmp")), false);
|
|
125
|
+
await lease.write({ ...pending, status: "resolved" }); // Definitely not sent.
|
|
126
|
+
await lease.release();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
void test("invalid UTF-8 must not silently change an opaque stored credit ID", async (t) => {
|
|
130
|
+
const { root, store } = await fixture(t);
|
|
131
|
+
const lease = await store.acquire(accountId);
|
|
132
|
+
const content = Buffer.from(JSON.stringify(pending));
|
|
133
|
+
content[content.indexOf("synthetic-credit")] = 0xff;
|
|
134
|
+
await writeFile(join(root, `${basename}.json`), content, { mode: 0o600 });
|
|
135
|
+
await assert.rejects(lease.read());
|
|
136
|
+
await lease.release();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
void test("resolved request IDs cannot be reused for a new logical attempt", async (t) => {
|
|
140
|
+
const { store } = await fixture(t);
|
|
141
|
+
const lease = await store.acquire(accountId);
|
|
142
|
+
await lease.write(pending);
|
|
143
|
+
await lease.write({ ...pending, status: "resolved", confirmedCode: "reset" });
|
|
144
|
+
await assert.rejects(lease.write(pending));
|
|
145
|
+
await lease.release();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
async function fileHandlePrototype(root: string): Promise<FileHandle> {
|
|
149
|
+
const probe = await open(join(root, "synthetic-probe"), "wx", 0o600);
|
|
150
|
+
const prototype = Object.getPrototypeOf(probe) as FileHandle;
|
|
151
|
+
await probe.close();
|
|
152
|
+
return prototype;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
void test("failed temporary write leaves pending intact and cleans only its own temporary file", async (t) => {
|
|
156
|
+
const { root, store } = await fixture(t);
|
|
157
|
+
const lease = await store.acquire(accountId);
|
|
158
|
+
await lease.write(pending);
|
|
159
|
+
const prototype = await fileHandlePrototype(root);
|
|
160
|
+
const fault = t.mock.method(prototype, "writeFile", () => Promise.reject(new Error("synthetic write fault")));
|
|
161
|
+
await assert.rejects(lease.write({ ...pending, status: "resolved", confirmedCode: "reset" }));
|
|
162
|
+
fault.mock.restore();
|
|
163
|
+
assert.deepEqual(await lease.read(), pending);
|
|
164
|
+
assert.equal((await readdir(root)).some((name) => name.endsWith(".tmp")), false);
|
|
165
|
+
await lease.release();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
for (const restoreFails of [false, true]) {
|
|
169
|
+
void test(`post-rename terminal fsync failure restores pending or retains lock (${restoreFails})`, async (t) => {
|
|
170
|
+
const { root, store } = await fixture(t);
|
|
171
|
+
const lease = await store.acquire(accountId);
|
|
172
|
+
await lease.write(pending);
|
|
173
|
+
const prototype = await fileHandlePrototype(root);
|
|
174
|
+
const original = Reflect.get(prototype, "sync");
|
|
175
|
+
let directorySyncs = 0;
|
|
176
|
+
const fault = t.mock.method(prototype, "sync", async function (this: FileHandle) {
|
|
177
|
+
if ((await this.stat()).isDirectory() && (++directorySyncs === 1 || restoreFails)) throw new Error("synthetic fsync fault");
|
|
178
|
+
await original.call(this);
|
|
179
|
+
});
|
|
180
|
+
await assert.rejects(lease.write({ ...pending, status: "resolved", confirmedCode: "reset" }));
|
|
181
|
+
fault.mock.restore();
|
|
182
|
+
assert.deepEqual(JSON.parse(await readFile(join(root, `${basename}.json`), "utf8")) as unknown, pending);
|
|
183
|
+
if (restoreFails) {
|
|
184
|
+
await assert.rejects(lease.release());
|
|
185
|
+
await assert.rejects(store.acquire(accountId));
|
|
186
|
+
} else {
|
|
187
|
+
await lease.release();
|
|
188
|
+
const next = await store.acquire(accountId);
|
|
189
|
+
assert.deepEqual(await next.read(), pending);
|
|
190
|
+
await next.release();
|
|
191
|
+
}
|
|
192
|
+
assert.equal((await readdir(root)).some((name) => name.endsWith(".tmp")), false);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
void test("accepts the byte limit but never reads beyond it", async (t) => {
|
|
197
|
+
const { root, store } = await fixture(t);
|
|
198
|
+
const lease = await store.acquire(accountId);
|
|
199
|
+
const serialized = JSON.stringify(pending);
|
|
200
|
+
await writeFile(join(root, `${basename}.json`), serialized.padEnd(8_192, " "), { mode: 0o600 });
|
|
201
|
+
assert.deepEqual(await lease.read(), pending);
|
|
202
|
+
await writeFile(join(root, `${basename}.json`), serialized.padEnd(8_193, " "), { mode: 0o600 });
|
|
203
|
+
await assert.rejects(lease.read());
|
|
204
|
+
await lease.release();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
void test("no terminal record without pending, no operations after lease release", async (t) => {
|
|
208
|
+
const { store } = await fixture(t);
|
|
209
|
+
const lease = await store.acquire(accountId);
|
|
210
|
+
await assert.rejects(lease.write({ ...pending, status: "resolved", confirmedCode: "reset" }));
|
|
211
|
+
assert.equal(await lease.read(), undefined);
|
|
212
|
+
await lease.release();
|
|
213
|
+
await assert.rejects(lease.read());
|
|
214
|
+
await assert.rejects(lease.write(pending));
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
void test("write snapshots validated fields before asynchronous IO and cannot serialize inherited credentials", async (t) => {
|
|
218
|
+
const { root, store } = await fixture(t);
|
|
219
|
+
const lease = await store.acquire(accountId);
|
|
220
|
+
const input = { ...pending };
|
|
221
|
+
Object.setPrototypeOf(input, { toJSON: () => ({ ...pending, accessToken: "synthetic-forbidden" }) });
|
|
222
|
+
const saving = lease.write(input);
|
|
223
|
+
Object.assign(input, { creditId: "x".repeat(8_193), accessToken: "synthetic-forbidden" });
|
|
224
|
+
await saving;
|
|
225
|
+
assert.deepEqual(await lease.read(), pending);
|
|
226
|
+
assert.equal((await readFile(join(root, `${basename}.json`), "utf8")).includes("synthetic-forbidden"), false);
|
|
227
|
+
await lease.release();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
void test("lock release failure is generic and refuses a subsequent lease", async (t) => {
|
|
231
|
+
const { root, store } = await fixture(t);
|
|
232
|
+
const lease = await store.acquire(accountId);
|
|
233
|
+
await chmod(join(root, `${basename}.lock`), 0o644);
|
|
234
|
+
await assert.rejects(lease.release(), /^Error: Codex reset journal is unavailable$/);
|
|
235
|
+
await assert.rejects(store.acquire(accountId));
|
|
236
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
|
|
4
|
+
import type { FileHandle } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { ConsumeCodexResetCode } from "./codex-usage.js";
|
|
7
|
+
|
|
8
|
+
/** Resolved without confirmedCode means definitely not sent, never inferred from GET. */
|
|
9
|
+
export type CodexResetRecord = {
|
|
10
|
+
version: 1;
|
|
11
|
+
accountId: string;
|
|
12
|
+
creditId: string;
|
|
13
|
+
requestId: string;
|
|
14
|
+
expiresAt?: number;
|
|
15
|
+
} & ({ status: "pending" } | { status: "resolved"; confirmedCode?: ConsumeCodexResetCode });
|
|
16
|
+
|
|
17
|
+
export type CodexResetLease = {
|
|
18
|
+
read(): Promise<CodexResetRecord | undefined>;
|
|
19
|
+
write(record: CodexResetRecord): Promise<void>;
|
|
20
|
+
release(): Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type CodexResetStore = { acquire(accountId: string): Promise<CodexResetLease> };
|
|
24
|
+
|
|
25
|
+
const MAX_BYTES = 8_192;
|
|
26
|
+
const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
|
27
|
+
const CODES = new Set(["reset", "nothing_to_reset", "no_credit", "already_redeemed"]);
|
|
28
|
+
const unavailable = () => new Error("Codex reset journal is unavailable");
|
|
29
|
+
const isMissing = (error: unknown) => !!error && typeof error === "object" && "code" in error && error.code === "ENOENT";
|
|
30
|
+
const validId = (value: unknown): value is string => typeof value === "string" && value.length <= 512 && !!value.trim() &&
|
|
31
|
+
!Array.from(value).some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127);
|
|
32
|
+
|
|
33
|
+
function validateRecord(value: unknown, accountId: string): CodexResetRecord {
|
|
34
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw unavailable();
|
|
35
|
+
// Capture own values once; never serialize caller prototypes, toJSON hooks, or later mutations.
|
|
36
|
+
const record = { ...value } as Record<string, unknown>;
|
|
37
|
+
const keys = ["version", "accountId", "creditId", "requestId", "expiresAt", "status", "confirmedCode"];
|
|
38
|
+
if (Object.getOwnPropertyNames(value).some((key) => !keys.includes(key)) || record.version !== 1 || record.accountId !== accountId ||
|
|
39
|
+
!validId(record.creditId) || typeof record.requestId !== "string" || !UUID.test(record.requestId) ||
|
|
40
|
+
(record.expiresAt !== undefined && (typeof record.expiresAt !== "number" || !Number.isSafeInteger(record.expiresAt) || record.expiresAt < 0)) ||
|
|
41
|
+
(record.status !== "pending" && record.status !== "resolved") ||
|
|
42
|
+
(record.confirmedCode !== undefined && (record.status !== "resolved" || typeof record.confirmedCode !== "string" || !CODES.has(record.confirmedCode))))
|
|
43
|
+
throw unavailable();
|
|
44
|
+
return record as CodexResetRecord;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sameAttempt(left: CodexResetRecord, right: CodexResetRecord): boolean {
|
|
48
|
+
return left.accountId === right.accountId && left.creditId === right.creditId &&
|
|
49
|
+
left.requestId === right.requestId && left.expiresAt === right.expiresAt;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The injected root must have trusted parents. No auth material enters this edge. */
|
|
53
|
+
export function createCodexResetStore(rootDirectory: string): CodexResetStore {
|
|
54
|
+
return {
|
|
55
|
+
async acquire(accountId) {
|
|
56
|
+
let lock: FileHandle | undefined;
|
|
57
|
+
try {
|
|
58
|
+
if (!validId(accountId)) throw unavailable();
|
|
59
|
+
await mkdir(rootDirectory, { recursive: true, mode: 0o700 });
|
|
60
|
+
const root = await lstat(rootDirectory);
|
|
61
|
+
if (!root.isDirectory() || root.isSymbolicLink() || (root.mode & 0o777) !== 0o700) throw unavailable();
|
|
62
|
+
const hash = createHash("sha256").update(accountId).digest("hex");
|
|
63
|
+
const journalPath = join(rootDirectory, `${hash}.json`);
|
|
64
|
+
const lockPath = join(rootDirectory, `${hash}.lock`);
|
|
65
|
+
// Existing locks (including empty, stale, or symlink locks) are never reclaimed.
|
|
66
|
+
lock = await open(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
67
|
+
const heldLock = lock;
|
|
68
|
+
const lockStat = await heldLock.stat();
|
|
69
|
+
let released = false;
|
|
70
|
+
let poisoned = false;
|
|
71
|
+
|
|
72
|
+
async function checkRoot() {
|
|
73
|
+
if (released || poisoned) throw unavailable();
|
|
74
|
+
const current = await lstat(rootDirectory);
|
|
75
|
+
if (!current.isDirectory() || current.isSymbolicLink() || current.dev !== root.dev || current.ino !== root.ino ||
|
|
76
|
+
(current.mode & 0o777) !== 0o700) throw unavailable();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function read(): Promise<CodexResetRecord | undefined> {
|
|
80
|
+
let file: FileHandle | undefined;
|
|
81
|
+
try {
|
|
82
|
+
await checkRoot();
|
|
83
|
+
try { file = await open(journalPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
84
|
+
catch (error) { if (isMissing(error)) return undefined; throw error; }
|
|
85
|
+
const info = await file.stat();
|
|
86
|
+
if (!info.isFile() || (info.mode & 0o777) !== 0o600 || info.size > MAX_BYTES) throw unavailable();
|
|
87
|
+
// A fixed buffer bounds reads even if another writer grows the file after stat.
|
|
88
|
+
const buffer = Buffer.alloc(MAX_BYTES + 1);
|
|
89
|
+
let size = 0;
|
|
90
|
+
while (size < buffer.length) {
|
|
91
|
+
const { bytesRead } = await file.read(buffer, size, buffer.length - size, size);
|
|
92
|
+
if (bytesRead === 0) break;
|
|
93
|
+
size += bytesRead;
|
|
94
|
+
}
|
|
95
|
+
if (size > MAX_BYTES) throw unavailable();
|
|
96
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, size));
|
|
97
|
+
return validateRecord(JSON.parse(text) as unknown, accountId);
|
|
98
|
+
} catch { throw unavailable(); }
|
|
99
|
+
finally { await file?.close().catch(() => { throw unavailable(); }); }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function syncDirectory() {
|
|
103
|
+
const directory = await open(rootDirectory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
104
|
+
try { await directory.sync(); }
|
|
105
|
+
finally { await directory.close(); }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function write(record: CodexResetRecord): Promise<void> {
|
|
109
|
+
let previous: CodexResetRecord | undefined;
|
|
110
|
+
let published = false;
|
|
111
|
+
async function atomicSave(next: CodexResetRecord) {
|
|
112
|
+
const tempPath = join(rootDirectory, `${hash}.${randomUUID()}.tmp`);
|
|
113
|
+
let temp: FileHandle | undefined;
|
|
114
|
+
let created = false;
|
|
115
|
+
try {
|
|
116
|
+
await checkRoot();
|
|
117
|
+
temp = await open(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
118
|
+
created = true;
|
|
119
|
+
await temp.writeFile(JSON.stringify(next), "utf8");
|
|
120
|
+
await temp.sync();
|
|
121
|
+
await temp.close();
|
|
122
|
+
temp = undefined;
|
|
123
|
+
await checkRoot();
|
|
124
|
+
try {
|
|
125
|
+
const final = await lstat(journalPath);
|
|
126
|
+
if (!final.isFile() || final.isSymbolicLink() || (final.mode & 0o777) !== 0o600) throw unavailable();
|
|
127
|
+
} catch (error) { if (!isMissing(error)) throw error; }
|
|
128
|
+
await rename(tempPath, journalPath);
|
|
129
|
+
created = false;
|
|
130
|
+
published = true;
|
|
131
|
+
await syncDirectory();
|
|
132
|
+
} finally {
|
|
133
|
+
try { await temp?.close(); }
|
|
134
|
+
finally { if (created) await unlink(tempPath); }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
record = validateRecord(record, accountId);
|
|
139
|
+
if (Buffer.byteLength(JSON.stringify(record)) > MAX_BYTES) throw unavailable();
|
|
140
|
+
previous = await read();
|
|
141
|
+
if ((previous?.status === "pending" && !sameAttempt(previous, record)) ||
|
|
142
|
+
(previous?.status === "resolved" && record.status === "pending" && previous.requestId === record.requestId) ||
|
|
143
|
+
(record.status === "resolved" && (previous?.status !== "pending" || !sameAttempt(previous, record)))) throw unavailable();
|
|
144
|
+
await atomicSave(record);
|
|
145
|
+
} catch {
|
|
146
|
+
// A post-rename fsync failure must not expose a resolved gate. Restore pending;
|
|
147
|
+
// if even that fails, retain the lock rather than permit another attempt.
|
|
148
|
+
if (published && record.status === "resolved" && previous?.status === "pending") {
|
|
149
|
+
try { await atomicSave(previous); }
|
|
150
|
+
catch { poisoned = true; }
|
|
151
|
+
}
|
|
152
|
+
throw unavailable();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function release(): Promise<void> {
|
|
157
|
+
try {
|
|
158
|
+
await checkRoot();
|
|
159
|
+
const current = await lstat(lockPath);
|
|
160
|
+
if (!current.isFile() || (current.mode & 0o777) !== 0o600 || current.dev !== lockStat.dev || current.ino !== lockStat.ino)
|
|
161
|
+
throw unavailable();
|
|
162
|
+
await heldLock.close();
|
|
163
|
+
await unlink(lockPath);
|
|
164
|
+
} catch { throw unavailable(); }
|
|
165
|
+
finally { released = true; await heldLock.close().catch(() => {}); }
|
|
166
|
+
}
|
|
167
|
+
return { read, write, release };
|
|
168
|
+
} catch {
|
|
169
|
+
await lock?.close().catch(() => {}); // Never remove an uncertain or pre-existing lock.
|
|
170
|
+
throw unavailable();
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { registerHooks } from "node:module";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import type { CodexResetResult } from "./codex-reset.js";
|
|
7
|
+
registerHooks({ resolve(specifier, context, next) {
|
|
8
|
+
if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
|
|
9
|
+
const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
|
|
10
|
+
if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
|
|
11
|
+
}
|
|
12
|
+
return next(specifier, context);
|
|
13
|
+
} });
|
|
14
|
+
|
|
15
|
+
void test("choice labels map indexed local expiry to exact IDs, never render opaque identities", async () => {
|
|
16
|
+
const { createCodexResetInteraction } = await import("./codex-reset-ui.js");
|
|
17
|
+
const expiry = Date.parse("2030-07-17T12:30:00Z");
|
|
18
|
+
const choices = [{ creditId: "opaque-first", expiresAt: expiry }, { creditId: "opaque-second", expiresAt: undefined }];
|
|
19
|
+
let answer: number | string | undefined = 1;
|
|
20
|
+
const interaction = createCodexResetInteraction({ select: (_title, options) => {
|
|
21
|
+
assert.deepEqual(options, [`Reset 1 — expires ${new Date(expiry).toLocaleString()} (local time)`, "Reset 2 — Expiry not provided"]);
|
|
22
|
+
assert.doesNotMatch(options.join(""), /opaque-/);
|
|
23
|
+
return Promise.resolve(typeof answer === "number" ? options[answer] : answer);
|
|
24
|
+
} });
|
|
25
|
+
assert.equal(await interaction.choose(choices), "opaque-second");
|
|
26
|
+
for (answer of [undefined, "invented", "opaque-first"]) assert.equal(await interaction.choose(choices), undefined);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
void test("new and recovery confirmation use Cancel-first select with honest scope/effects/date", async () => {
|
|
30
|
+
const { createCodexResetInteraction } = await import("./codex-reset-ui.js");
|
|
31
|
+
for (const kind of ["new", "recovery"] as const) {
|
|
32
|
+
for (const accept of [false, true, undefined]) {
|
|
33
|
+
const interaction = createCodexResetInteraction({ select: (title, options) => {
|
|
34
|
+
assert.match(title, /active Codex account/);
|
|
35
|
+
assert.match(title, /Expiry not provided/);
|
|
36
|
+
assert.doesNotMatch(title, /opaque-/);
|
|
37
|
+
assert.deepEqual(options, ["Cancel", kind === "new" ? "Use reset" : "Check pending attempt"]);
|
|
38
|
+
if (kind === "new") assert.match(title, /one banked reset/);
|
|
39
|
+
else assert.match(title, /same previous request.*may finish.*not a new attempt/i);
|
|
40
|
+
return Promise.resolve(accept === undefined ? undefined : options[accept ? 1 : 0]);
|
|
41
|
+
} });
|
|
42
|
+
const base = { scope: "active-account", cancelFirst: true, creditId: "opaque-credit", expiresAt: undefined } as const;
|
|
43
|
+
const prompt = kind === "recovery" ? { ...base, kind, requestId: "opaque-request" } : { ...base, kind };
|
|
44
|
+
assert.equal(await interaction.confirm(prompt), accept === true);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
void test("confirmed codes remain definite and distinguish refresh/journal failures", async () => {
|
|
50
|
+
const { notifyCodexResetResult } = await import("./codex-reset-ui.js");
|
|
51
|
+
for (const code of ["reset", "already_redeemed", "no_credit", "nothing_to_reset"] as const) {
|
|
52
|
+
const notices: string[] = [];
|
|
53
|
+
notifyCodexResetResult({ notify: text => { notices.push(text); } }, { kind: "confirmed", code, windowsReset: 2, refreshFailed: true, journalFailed: true });
|
|
54
|
+
assert.match(notices[0] ?? "", code === "reset" ? /consumed.*2.*windows/i : code === "already_redeemed" ? /previous.*succeeded/i : /not consumed/i);
|
|
55
|
+
assert.match(notices[0] ?? "", /Quota refresh failed/);
|
|
56
|
+
assert.match(notices[0] ?? "", /Local attempt bookkeeping failed/);
|
|
57
|
+
assert.doesNotMatch(notices[0] ?? "", /reset failed|Outcome unknown/i);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
void test("unknown and blocked feedback never suggests another reset or automatic lock cleanup", async () => {
|
|
62
|
+
const { notifyCodexResetResult } = await import("./codex-reset-ui.js");
|
|
63
|
+
const results: CodexResetResult[] = [{ kind: "unknown", refreshFailed: false, journalFailed: false },
|
|
64
|
+
{ kind: "blocked", journalFailed: true }, { kind: "busy" }, { kind: "unavailable" }, { kind: "cancelled" }];
|
|
65
|
+
const notices: string[] = [];
|
|
66
|
+
for (const result of results) notifyCodexResetResult({ notify: text => { notices.push(text); } }, result);
|
|
67
|
+
assert.equal(notices[0], "Outcome unknown; do not start another reset. Use check to confirm the same pending attempt.");
|
|
68
|
+
assert.match(notices[1] ?? "", /blocked.*manual verification/i);
|
|
69
|
+
assert.doesNotMatch(notices.join(""), /opaque|token|\.json|auto.*reclaim/i);
|
|
70
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { CodexResetInteraction, CodexResetResult } from "./codex-reset.js";
|
|
3
|
+
|
|
4
|
+
export type CodexResetAction = () => Promise<boolean>;
|
|
5
|
+
const expiryLabel = (expiresAt: number | undefined): string => expiresAt === undefined
|
|
6
|
+
? "Expiry not provided" : `expires ${new Date(expiresAt).toLocaleString()} (local time)`;
|
|
7
|
+
|
|
8
|
+
/** Presentation only. Opaque IDs stay in the indexed mapping, not dialog text. */
|
|
9
|
+
export function createCodexResetInteraction(ui: Pick<ExtensionContext["ui"], "select">): CodexResetInteraction {
|
|
10
|
+
return {
|
|
11
|
+
async choose(choices) {
|
|
12
|
+
const labels = choices.map((choice, index) => `Reset ${index + 1} — ${expiryLabel(choice.expiresAt)}`);
|
|
13
|
+
const selected = await ui.select("Choose a banked reset for the active Codex account", labels);
|
|
14
|
+
const index = selected === undefined ? -1 : labels.indexOf(selected);
|
|
15
|
+
return choices[index]?.creditId;
|
|
16
|
+
},
|
|
17
|
+
async confirm(prompt) {
|
|
18
|
+
const recovery = prompt.kind === "recovery";
|
|
19
|
+
const effect = recovery
|
|
20
|
+
? "Check the same previous request; this may finish the previous unconfirmed reset, not a new attempt."
|
|
21
|
+
: "Use one banked reset; one banked reset will be consumed if rate limits are reset.";
|
|
22
|
+
const accept = recovery ? "Check pending attempt" : "Use reset";
|
|
23
|
+
return await ui.select(`For the active Codex account: ${effect}\nReset ${expiryLabel(prompt.expiresAt)}.`, ["Cancel", accept]) === accept;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Definite server outcomes are never relabelled as failures by later local work. */
|
|
29
|
+
export function notifyCodexResetResult(ui: Pick<ExtensionContext["ui"], "notify">, result: CodexResetResult): void {
|
|
30
|
+
let message: string;
|
|
31
|
+
switch (result.kind) {
|
|
32
|
+
case "busy": message = "A banked reset action is already in progress."; break;
|
|
33
|
+
case "cancelled": message = "Banked reset action cancelled; no new reset was sent."; break;
|
|
34
|
+
case "unavailable": message = "No currently available banked reset could be selected. Refresh usage to check availability."; break;
|
|
35
|
+
case "blocked": message = "Banked reset action blocked. Local attempt storage or its lock is unavailable; manual verification is required. No automatic cleanup or retry."; break;
|
|
36
|
+
case "unknown": message = "Outcome unknown; do not start another reset. Use check to confirm the same pending attempt."; break;
|
|
37
|
+
case "confirmed":
|
|
38
|
+
switch (result.code) {
|
|
39
|
+
case "reset": message = `Banked reset consumed; ${result.windowsReset} rate-limit windows reset.`; break;
|
|
40
|
+
case "already_redeemed": message = "The previous reset already succeeded; this check did not consume another reset."; break;
|
|
41
|
+
case "no_credit": message = "No banked reset was available; a reset was not consumed."; break;
|
|
42
|
+
case "nothing_to_reset": message = "No rate limits needed resetting; a banked reset was not consumed."; break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (result.kind === "unknown" || result.kind === "confirmed") {
|
|
46
|
+
if (result.refreshFailed) message += " Quota refresh failed; the consumption outcome above is unchanged.";
|
|
47
|
+
if (result.journalFailed) message += " Local attempt bookkeeping failed; do not start another reset before manual verification.";
|
|
48
|
+
}
|
|
49
|
+
const warning = result.kind === "unknown" || result.kind === "blocked" || result.kind === "busy" || result.kind === "unavailable" ||
|
|
50
|
+
(result.kind === "confirmed" && (result.refreshFailed || result.journalFailed));
|
|
51
|
+
ui.notify(message, warning ? "warning" : "info");
|
|
52
|
+
}
|