@desplega.ai/agent-swarm 1.83.1 → 1.83.2
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/openapi.json +139 -8
- package/package.json +1 -1
- package/src/artifact-sdk/server.ts +23 -1
- package/src/be/budget-admission.ts +28 -4
- package/src/be/budget-refusal-notify.ts +19 -3
- package/src/be/db-queries/oauth.ts +43 -0
- package/src/be/db.ts +35 -2
- package/src/be/migrations/074_user_budget_scope.sql +85 -0
- package/src/commands/resume-session.ts +118 -0
- package/src/commands/runner.ts +137 -67
- package/src/http/core.ts +4 -1
- package/src/http/index.ts +16 -0
- package/src/http/integrations.ts +26 -0
- package/src/http/mcp-user.ts +111 -0
- package/src/http/poll.ts +19 -5
- package/src/http/schedules.ts +1 -1
- package/src/http/users.ts +107 -2
- package/src/jira/client.ts +3 -5
- package/src/jira/oauth.ts +1 -0
- package/src/jira/sync.ts +2 -2
- package/src/oauth/ensure-token.ts +1 -0
- package/src/oauth/wrapper.ts +38 -7
- package/src/providers/claude-adapter.ts +7 -2
- package/src/providers/claude-managed-adapter.ts +1 -1
- package/src/providers/codex-adapter.ts +30 -0
- package/src/providers/opencode-adapter.ts +149 -14
- package/src/providers/pi-mono-adapter.ts +41 -1
- package/src/providers/types.ts +1 -1
- package/src/server-user.ts +117 -0
- package/src/tests/artifact-sdk.test.ts +23 -19
- package/src/tests/budget-user-scope.test.ts +376 -0
- package/src/tests/claude-managed-adapter.test.ts +6 -0
- package/src/tests/codex-adapter.test.ts +192 -0
- package/src/tests/codex-rate-limit-parse.test.ts +256 -0
- package/src/tests/db-queries-oauth.test.ts +43 -0
- package/src/tests/ensure-token.test.ts +93 -0
- package/src/tests/error-tracker.test.ts +52 -0
- package/src/tests/fetch-resolved-env.test.ts +33 -20
- package/src/tests/http-users.test.ts +29 -1
- package/src/tests/mcp-user-route.test.ts +325 -0
- package/src/tests/opencode-adapter.test.ts +75 -0
- package/src/tests/pi-mono-adapter.test.ts +21 -1
- package/src/tests/rate-limit-event.test.ts +69 -6
- package/src/tests/resume-session.test.ts +93 -0
- package/src/tests/task-tools-ctx.test.ts +100 -0
- package/src/tests/task-tools-ownership.test.ts +167 -0
- package/src/tests/user-token-routes.test.ts +221 -0
- package/src/tools/cancel-task.ts +137 -83
- package/src/tools/get-task-details.ts +73 -59
- package/src/tools/get-tasks.ts +134 -126
- package/src/tools/send-task.ts +312 -312
- package/src/tools/task-action.ts +464 -367
- package/src/tools/task-tool-ctx.ts +43 -0
- package/src/types.ts +6 -2
- package/src/utils/error-tracker.ts +122 -9
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
MAX_RATE_LIMIT_RESET_MS,
|
|
4
|
+
parseCodexRateLimitResetTime,
|
|
5
|
+
SessionErrorTracker,
|
|
6
|
+
} from "../utils/error-tracker";
|
|
7
|
+
|
|
8
|
+
// Verbatim from Linear CAI-1284 issue body (Team/Business plan fixture)
|
|
9
|
+
const VERBATIM_ERROR_MESSAGE =
|
|
10
|
+
"You've hit your usage limit. To get more access now, send a request to your admin or try again at 8:35 PM.";
|
|
11
|
+
|
|
12
|
+
describe("parseCodexRateLimitResetTime — verbatim CAI-1284 fixture", () => {
|
|
13
|
+
test("parses '8:35 PM' as 20:35 UTC same day when now is 18:00 UTC", () => {
|
|
14
|
+
const now = new Date("2026-05-25T18:00:00Z");
|
|
15
|
+
const result = parseCodexRateLimitResetTime(VERBATIM_ERROR_MESSAGE, now);
|
|
16
|
+
expect(result).toBe("2026-05-25T20:35:00.000Z");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("rolls to next day when 'now' is past the parsed wall-clock", () => {
|
|
20
|
+
const now = new Date("2026-05-25T21:00:00Z"); // 9:00 PM UTC — past 8:35 PM
|
|
21
|
+
const result = parseCodexRateLimitResetTime(VERBATIM_ERROR_MESSAGE, now);
|
|
22
|
+
expect(result).toBe("2026-05-26T20:35:00.000Z");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("keeps same day when 'now' equals the parsed wall-clock exactly (clock-skew window)", () => {
|
|
26
|
+
const now = new Date("2026-05-25T20:35:00Z");
|
|
27
|
+
const result = parseCodexRateLimitResetTime(VERBATIM_ERROR_MESSAGE, now);
|
|
28
|
+
// Within 2-min skew window → same day; clampRateLimitResetMs applies now+60s floor.
|
|
29
|
+
expect(result).toBe("2026-05-25T20:35:00.000Z");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("clock-skew regression: 30s past 8:35 PM does NOT roll to tomorrow (CAI-1284)", () => {
|
|
33
|
+
// Worker receives the usage-limit event 30 seconds after the wall-clock reset time.
|
|
34
|
+
// Should stay same-day; clampRateLimitResetMs applies now+60s floor instead.
|
|
35
|
+
const now = new Date("2026-05-25T20:35:30Z");
|
|
36
|
+
const result = parseCodexRateLimitResetTime(VERBATIM_ERROR_MESSAGE, now);
|
|
37
|
+
expect(result).toBe("2026-05-25T20:35:00.000Z");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("parseCodexRateLimitResetTime — same-day format variants", () => {
|
|
42
|
+
test.each([
|
|
43
|
+
// [time string, expected ISO, now ISO]
|
|
44
|
+
["12:00 AM", "2026-05-25T00:00:00.000Z", "2026-05-25T00:00:00Z"], // midnight == now: within skew, stays same day
|
|
45
|
+
["12:00 PM", "2026-05-25T12:00:00.000Z", "2026-05-25T00:00:00Z"], // noon: future
|
|
46
|
+
["1:00 PM", "2026-05-25T13:00:00.000Z", "2026-05-25T00:00:00Z"],
|
|
47
|
+
["11:59 PM", "2026-05-25T23:59:00.000Z", "2026-05-25T00:00:00Z"],
|
|
48
|
+
])("'Try again at %s.' → %s", (time, expected, nowISO) => {
|
|
49
|
+
const now = new Date(nowISO);
|
|
50
|
+
const msg = `You've hit your usage limit. Try again at ${time}.`;
|
|
51
|
+
expect(parseCodexRateLimitResetTime(msg, now)).toBe(expected);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("'or try again at' prefix (lowercase) also parses", () => {
|
|
55
|
+
const now = new Date("2026-05-25T18:00:00Z");
|
|
56
|
+
const msg =
|
|
57
|
+
"You've hit your usage limit. To get more access now, send a request to your admin or try again at 8:35 PM.";
|
|
58
|
+
expect(parseCodexRateLimitResetTime(msg, now)).toBe("2026-05-25T20:35:00.000Z");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("parseCodexRateLimitResetTime — different-day format", () => {
|
|
63
|
+
test("'May 26th, 2026 8:35 PM' parses with ordinal suffix", () => {
|
|
64
|
+
const now = new Date("2026-05-25T22:00:00Z");
|
|
65
|
+
const msg =
|
|
66
|
+
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), " +
|
|
67
|
+
"visit https://chatgpt.com/codex/settings/usage to purchase more credits or " +
|
|
68
|
+
"try again at May 26th, 2026 8:35 PM.";
|
|
69
|
+
expect(parseCodexRateLimitResetTime(msg, now)).toBe("2026-05-26T20:35:00.000Z");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("ordinal-less form 'May 26, 2026 8:35 PM' also parses (defensive)", () => {
|
|
73
|
+
const now = new Date("2026-05-25T22:00:00Z");
|
|
74
|
+
const msg = "You've hit your usage limit. Try again at May 26, 2026 8:35 PM.";
|
|
75
|
+
expect(parseCodexRateLimitResetTime(msg, now)).toBe("2026-05-26T20:35:00.000Z");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test.each([
|
|
79
|
+
["1st", 1],
|
|
80
|
+
["2nd", 2],
|
|
81
|
+
["3rd", 3],
|
|
82
|
+
["4th", 4],
|
|
83
|
+
["11th", 11],
|
|
84
|
+
["21st", 21],
|
|
85
|
+
["22nd", 22],
|
|
86
|
+
["23rd", 23],
|
|
87
|
+
])("ordinal suffix %s parses", (ord, day) => {
|
|
88
|
+
const now = new Date("2026-04-01T00:00:00Z");
|
|
89
|
+
const msg = `You've hit your usage limit. Try again at May ${day}${ord.replace(/\d+/, "")}, 2026 8:35 PM.`;
|
|
90
|
+
const result = parseCodexRateLimitResetTime(msg, now);
|
|
91
|
+
expect(result).toBe(`2026-05-${String(day).padStart(2, "0")}T20:35:00.000Z`);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("12-hour edge: midnight cross-day 'Jan 1st, 2027 12:00 AM'", () => {
|
|
95
|
+
const now = new Date("2026-12-31T23:00:00Z");
|
|
96
|
+
const msg = "You've hit your usage limit. Try again at Jan 1st, 2027 12:00 AM.";
|
|
97
|
+
expect(parseCodexRateLimitResetTime(msg, now)).toBe("2027-01-01T00:00:00.000Z");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("12-hour edge: noon cross-day 'Jan 2nd, 2027 12:00 PM'", () => {
|
|
101
|
+
const now = new Date("2026-12-31T23:00:00Z");
|
|
102
|
+
const msg = "You've hit your usage limit. Try again at Jan 2nd, 2027 12:00 PM.";
|
|
103
|
+
expect(parseCodexRateLimitResetTime(msg, now)).toBe("2027-01-02T12:00:00.000Z");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("parseCodexRateLimitResetTime — negative cases", () => {
|
|
108
|
+
test("'Try again later.' (no time) → undefined", () => {
|
|
109
|
+
const msg = "You've hit your usage limit. Try again later.";
|
|
110
|
+
expect(parseCodexRateLimitResetTime(msg)).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("'or try again later.' (no time) → undefined", () => {
|
|
114
|
+
const msg =
|
|
115
|
+
"You've hit your usage limit. To get more access now, send a request to your admin or try again later.";
|
|
116
|
+
expect(parseCodexRateLimitResetTime(msg)).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("workspace-credit-depleted (no retry suffix at all) → undefined", () => {
|
|
120
|
+
expect(
|
|
121
|
+
parseCodexRateLimitResetTime("Your workspace is out of credits. Add credits to continue."),
|
|
122
|
+
).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("workspace member out of credits → undefined", () => {
|
|
126
|
+
expect(
|
|
127
|
+
parseCodexRateLimitResetTime(
|
|
128
|
+
"Your workspace is out of credits. Ask your workspace owner to refill in order to continue.",
|
|
129
|
+
),
|
|
130
|
+
).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("non-codex error message → undefined", () => {
|
|
134
|
+
expect(parseCodexRateLimitResetTime("Connection failed: ECONNREFUSED")).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("empty string → undefined", () => {
|
|
138
|
+
expect(parseCodexRateLimitResetTime("")).toBeUndefined();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("parseCodexRateLimitResetTime — invalid component rejection (CAI-1284 review)", () => {
|
|
143
|
+
const now = new Date("2026-05-25T18:00:00Z");
|
|
144
|
+
|
|
145
|
+
test("'Try again at 99:99 PM.' → undefined (hour and minute out of range)", () => {
|
|
146
|
+
expect(
|
|
147
|
+
parseCodexRateLimitResetTime("usage limit. Try again at 99:99 PM.", now),
|
|
148
|
+
).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("'Try again at 13:99 PM.' → undefined (hour out of range)", () => {
|
|
152
|
+
expect(
|
|
153
|
+
parseCodexRateLimitResetTime("usage limit. Try again at 13:99 PM.", now),
|
|
154
|
+
).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("'Try again at 0:30 PM.' → undefined (hour 0 is not 1–12)", () => {
|
|
158
|
+
expect(parseCodexRateLimitResetTime("usage limit. Try again at 0:30 PM.", now)).toBeUndefined();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("'Try again at 12:60 PM.' → undefined (minute 60 out of range)", () => {
|
|
162
|
+
expect(
|
|
163
|
+
parseCodexRateLimitResetTime("usage limit. Try again at 12:60 PM.", now),
|
|
164
|
+
).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("'Try again at May 32nd, 2026 8:35 PM.' → undefined (day overflow)", () => {
|
|
168
|
+
expect(
|
|
169
|
+
parseCodexRateLimitResetTime("usage limit. Try again at May 32nd, 2026 8:35 PM.", now),
|
|
170
|
+
).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("'Try again at Feb 30th, 2026 8:35 PM.' → undefined (Feb has ≤29 days)", () => {
|
|
174
|
+
expect(
|
|
175
|
+
parseCodexRateLimitResetTime("usage limit. Try again at Feb 30th, 2026 8:35 PM.", now),
|
|
176
|
+
).toBeUndefined();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("'Try again at May 26th, 2026 8:35 PM.' → valid (positive control)", () => {
|
|
180
|
+
expect(
|
|
181
|
+
parseCodexRateLimitResetTime("usage limit. Try again at May 26th, 2026 8:35 PM.", now),
|
|
182
|
+
).toBe("2026-05-26T20:35:00.000Z");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("parseCodexRateLimitResetTime — case-insensitive", () => {
|
|
187
|
+
test("'TRY AGAIN AT 8:35 PM' (all caps) parses", () => {
|
|
188
|
+
const now = new Date("2026-05-25T00:00:00Z");
|
|
189
|
+
const result = parseCodexRateLimitResetTime("usage limit. TRY AGAIN AT 8:35 PM.", now);
|
|
190
|
+
expect(result).toBe("2026-05-25T20:35:00.000Z");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("SessionErrorTracker — Codex usage-limit integration", () => {
|
|
195
|
+
test("stashes clamped reset time from verbatim CAI-1284 fixture", () => {
|
|
196
|
+
const tracker = new SessionErrorTracker();
|
|
197
|
+
tracker.processCodexUsageLimitMessage(VERBATIM_ERROR_MESSAGE);
|
|
198
|
+
const iso = tracker.getRateLimitResetAt();
|
|
199
|
+
expect(iso).toBeDefined();
|
|
200
|
+
const ms = new Date(iso!).getTime();
|
|
201
|
+
const nowMs = Date.now();
|
|
202
|
+
// Bounded to [now+60s, now+7d]. The same-day wall-clock fixture may be >6h away.
|
|
203
|
+
expect(ms).toBeGreaterThanOrEqual(nowMs + 59_000);
|
|
204
|
+
expect(ms).toBeLessThanOrEqual(nowMs + MAX_RATE_LIMIT_RESET_MS + 1000);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("non-usage-limit error does not stash", () => {
|
|
208
|
+
const tracker = new SessionErrorTracker();
|
|
209
|
+
tracker.processCodexUsageLimitMessage("Connection failed.");
|
|
210
|
+
expect(tracker.getRateLimitResetAt()).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("workspace-credit message does not stash (no parseable time)", () => {
|
|
214
|
+
const tracker = new SessionErrorTracker();
|
|
215
|
+
tracker.processCodexUsageLimitMessage(
|
|
216
|
+
"Your workspace is out of credits. Add credits to continue.",
|
|
217
|
+
);
|
|
218
|
+
expect(tracker.getRateLimitResetAt()).toBeUndefined();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("'try again later' variant does not stash (no parseable time)", () => {
|
|
222
|
+
const tracker = new SessionErrorTracker();
|
|
223
|
+
tracker.processCodexUsageLimitMessage(
|
|
224
|
+
"You've hit your usage limit. To get more access now, send a request to your admin or try again later.",
|
|
225
|
+
);
|
|
226
|
+
expect(tracker.getRateLimitResetAt()).toBeUndefined();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("last call wins on multiple usage-limit events", () => {
|
|
230
|
+
const tracker = new SessionErrorTracker();
|
|
231
|
+
// First: a past-sounding time that would be clamped to now+60s
|
|
232
|
+
tracker.processCodexUsageLimitMessage("You've hit your usage limit. Try again at 1:00 AM.");
|
|
233
|
+
const firstIso = tracker.getRateLimitResetAt();
|
|
234
|
+
expect(firstIso).toBeDefined();
|
|
235
|
+
|
|
236
|
+
// Second: clear future time
|
|
237
|
+
const future = new Date(Date.now() + 3 * 60 * 60 * 1000); // +3h
|
|
238
|
+
const h = future.getUTCHours();
|
|
239
|
+
const m = future.getUTCMinutes();
|
|
240
|
+
const hh = h % 12 || 12;
|
|
241
|
+
const ampm = h >= 12 ? "PM" : "AM";
|
|
242
|
+
const mm = String(m).padStart(2, "0");
|
|
243
|
+
tracker.processCodexUsageLimitMessage(
|
|
244
|
+
`You've hit your usage limit. Try again at ${hh}:${mm} ${ampm}.`,
|
|
245
|
+
);
|
|
246
|
+
const secondIso = tracker.getRateLimitResetAt();
|
|
247
|
+
expect(secondIso).toBeDefined();
|
|
248
|
+
// Last call wins — value changed (may be same or different after clamp, but defined)
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("empty string does not stash", () => {
|
|
252
|
+
const tracker = new SessionErrorTracker();
|
|
253
|
+
tracker.processCodexUsageLimitMessage("");
|
|
254
|
+
expect(tracker.getRateLimitResetAt()).toBeUndefined();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
getOAuthTokens,
|
|
8
8
|
isTokenExpiringSoon,
|
|
9
9
|
storeOAuthTokens,
|
|
10
|
+
updateOAuthTokensAfterRefresh,
|
|
10
11
|
upsertOAuthApp,
|
|
11
12
|
} from "../be/db-queries/oauth";
|
|
12
13
|
|
|
@@ -140,6 +141,48 @@ describe("OAuth Tokens CRUD", () => {
|
|
|
140
141
|
expect(tokens!.refreshToken).toBe("refresh-xyz");
|
|
141
142
|
});
|
|
142
143
|
|
|
144
|
+
test("updateOAuthTokensAfterRefresh replaces the rotated refresh token atomically", () => {
|
|
145
|
+
const futureDate = new Date(Date.now() + 7200000).toISOString();
|
|
146
|
+
storeOAuthTokens("token-test", {
|
|
147
|
+
accessToken: "access-before-refresh",
|
|
148
|
+
refreshToken: "refresh-before-refresh",
|
|
149
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
updateOAuthTokensAfterRefresh("token-test", "refresh-before-refresh", {
|
|
153
|
+
accessToken: "access-after-refresh",
|
|
154
|
+
refreshToken: "refresh-after-refresh",
|
|
155
|
+
expiresAt: futureDate,
|
|
156
|
+
scope: "read,write",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const tokens = getOAuthTokens("token-test");
|
|
160
|
+
expect(tokens!.accessToken).toBe("access-after-refresh");
|
|
161
|
+
expect(tokens!.refreshToken).toBe("refresh-after-refresh");
|
|
162
|
+
expect(tokens!.expiresAt).toBe(futureDate);
|
|
163
|
+
expect(tokens!.scope).toBe("read,write");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("updateOAuthTokensAfterRefresh refuses to overwrite a concurrently rotated token", () => {
|
|
167
|
+
storeOAuthTokens("token-test", {
|
|
168
|
+
accessToken: "access-current",
|
|
169
|
+
refreshToken: "refresh-current",
|
|
170
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(() =>
|
|
174
|
+
updateOAuthTokensAfterRefresh("token-test", "refresh-stale", {
|
|
175
|
+
accessToken: "access-stale-result",
|
|
176
|
+
refreshToken: "refresh-stale-result",
|
|
177
|
+
expiresAt: new Date(Date.now() + 7200000).toISOString(),
|
|
178
|
+
}),
|
|
179
|
+
).toThrow(/stored refresh token changed during refresh/);
|
|
180
|
+
|
|
181
|
+
const tokens = getOAuthTokens("token-test");
|
|
182
|
+
expect(tokens!.accessToken).toBe("access-current");
|
|
183
|
+
expect(tokens!.refreshToken).toBe("refresh-current");
|
|
184
|
+
});
|
|
185
|
+
|
|
143
186
|
test("deleteOAuthTokens removes tokens", () => {
|
|
144
187
|
deleteOAuthTokens("token-test");
|
|
145
188
|
const tokens = getOAuthTokens("token-test");
|
|
@@ -25,10 +25,15 @@ const originalFetch = globalThis.fetch;
|
|
|
25
25
|
beforeAll(() => {
|
|
26
26
|
initDb(TEST_DB_PATH);
|
|
27
27
|
upsertOAuthApp("test-provider", testApp);
|
|
28
|
+
upsertOAuthApp("jira", {
|
|
29
|
+
...testApp,
|
|
30
|
+
tokenUrl: "https://example.com/jira/oauth/token",
|
|
31
|
+
});
|
|
28
32
|
});
|
|
29
33
|
|
|
30
34
|
beforeEach(() => {
|
|
31
35
|
deleteOAuthTokens("test-provider");
|
|
36
|
+
deleteOAuthTokens("jira");
|
|
32
37
|
globalThis.fetch = originalFetch;
|
|
33
38
|
});
|
|
34
39
|
|
|
@@ -266,4 +271,92 @@ describe("ensureTokenOrThrow", () => {
|
|
|
266
271
|
expect(tokens?.accessToken).toBe("rotated-token");
|
|
267
272
|
expect(tokens?.refreshToken).toBe("rotated-refresh");
|
|
268
273
|
});
|
|
274
|
+
|
|
275
|
+
test("persists Jira's rotated refresh token before reporting refresh success", async () => {
|
|
276
|
+
storeOAuthTokens("jira", {
|
|
277
|
+
accessToken: "old-jira-access",
|
|
278
|
+
refreshToken: "old-jira-refresh",
|
|
279
|
+
expiresAt: new Date(Date.now() + 50 * 60 * 1000).toISOString(),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
globalThis.fetch = mock(() =>
|
|
283
|
+
Promise.resolve(
|
|
284
|
+
new Response(
|
|
285
|
+
JSON.stringify({
|
|
286
|
+
access_token: "new-jira-access",
|
|
287
|
+
token_type: "Bearer",
|
|
288
|
+
expires_in: 3600,
|
|
289
|
+
refresh_token: "new-jira-refresh",
|
|
290
|
+
}),
|
|
291
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
292
|
+
),
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
await ensureTokenOrThrow("jira", Number.MAX_SAFE_INTEGER);
|
|
297
|
+
|
|
298
|
+
const tokens = getOAuthTokens("jira");
|
|
299
|
+
expect(tokens?.accessToken).toBe("new-jira-access");
|
|
300
|
+
expect(tokens?.refreshToken).toBe("new-jira-refresh");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("rejects a Jira refresh response that omits the rotated refresh token", async () => {
|
|
304
|
+
storeOAuthTokens("jira", {
|
|
305
|
+
accessToken: "old-jira-access",
|
|
306
|
+
refreshToken: "old-jira-refresh",
|
|
307
|
+
expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
globalThis.fetch = mock(() =>
|
|
311
|
+
Promise.resolve(
|
|
312
|
+
new Response(
|
|
313
|
+
JSON.stringify({
|
|
314
|
+
access_token: "new-jira-access",
|
|
315
|
+
token_type: "Bearer",
|
|
316
|
+
expires_in: 3600,
|
|
317
|
+
}),
|
|
318
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
319
|
+
),
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
await expect(ensureTokenOrThrow("jira")).rejects.toThrow(/rotated refresh_token/);
|
|
324
|
+
|
|
325
|
+
const tokens = getOAuthTokens("jira");
|
|
326
|
+
expect(tokens?.accessToken).toBe("old-jira-access");
|
|
327
|
+
expect(tokens?.refreshToken).toBe("old-jira-refresh");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("does not use a refreshed Jira access token when persistence loses the CAS race", async () => {
|
|
331
|
+
storeOAuthTokens("jira", {
|
|
332
|
+
accessToken: "old-jira-access",
|
|
333
|
+
refreshToken: "old-jira-refresh",
|
|
334
|
+
expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
globalThis.fetch = mock(() => {
|
|
338
|
+
storeOAuthTokens("jira", {
|
|
339
|
+
accessToken: "concurrent-jira-access",
|
|
340
|
+
refreshToken: "concurrent-jira-refresh",
|
|
341
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
342
|
+
});
|
|
343
|
+
return Promise.resolve(
|
|
344
|
+
new Response(
|
|
345
|
+
JSON.stringify({
|
|
346
|
+
access_token: "stale-result-access",
|
|
347
|
+
token_type: "Bearer",
|
|
348
|
+
expires_in: 3600,
|
|
349
|
+
refresh_token: "stale-result-refresh",
|
|
350
|
+
}),
|
|
351
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
352
|
+
),
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
await expect(ensureTokenOrThrow("jira")).rejects.toThrow(/stored refresh token changed/);
|
|
357
|
+
|
|
358
|
+
const tokens = getOAuthTokens("jira");
|
|
359
|
+
expect(tokens?.accessToken).toBe("concurrent-jira-access");
|
|
360
|
+
expect(tokens?.refreshToken).toBe("concurrent-jira-refresh");
|
|
361
|
+
});
|
|
269
362
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
|
+
parseCodexRateLimitResetTime,
|
|
3
4
|
parseRateLimitResetTime,
|
|
4
5
|
parseStderrForErrors,
|
|
5
6
|
SessionErrorTracker,
|
|
@@ -89,6 +90,23 @@ describe("SessionErrorTracker", () => {
|
|
|
89
90
|
expect(errors[0]!.type).toBe("stderr_error");
|
|
90
91
|
expect(errors[0]!.message).toBe("fatal: connection refused");
|
|
91
92
|
});
|
|
93
|
+
|
|
94
|
+
test("detects Claude CLI invalid --resume session errors as stale sessions", () => {
|
|
95
|
+
const tracker = new SessionErrorTracker();
|
|
96
|
+
trackErrorFromJson(
|
|
97
|
+
{
|
|
98
|
+
type: "result",
|
|
99
|
+
subtype: "error_during_execution",
|
|
100
|
+
is_error: true,
|
|
101
|
+
errors: [
|
|
102
|
+
'Error during execution: Error: --resume requires a valid session ID or session title when used with --print. Usage: claude -p --resume <session-id|title>. Provided value "ses_19c145de3ffeD9qLlntj8SRO28" is not a UUID and does not match any session title.',
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
tracker,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(tracker.isSessionNotFound()).toBe(true);
|
|
109
|
+
});
|
|
92
110
|
});
|
|
93
111
|
|
|
94
112
|
describe("buildFailureReason", () => {
|
|
@@ -548,3 +566,37 @@ describe("parseRateLimitResetTime", () => {
|
|
|
548
566
|
expect(parseRateLimitResetTime("wait 2000 minutes")).toBeUndefined();
|
|
549
567
|
});
|
|
550
568
|
});
|
|
569
|
+
|
|
570
|
+
describe("Codex/Claude coexistence — single tracker handles both providers", () => {
|
|
571
|
+
test("processRateLimitEvent (Claude) and processCodexUsageLimitMessage (Codex) both stash into getRateLimitResetAt", () => {
|
|
572
|
+
// Claude path: processRateLimitEvent
|
|
573
|
+
const claudeTracker = new SessionErrorTracker();
|
|
574
|
+
const futureResetsAtSec = Math.floor(Date.now() / 1000) + 3600;
|
|
575
|
+
claudeTracker.processRateLimitEvent({
|
|
576
|
+
type: "rate_limit_event",
|
|
577
|
+
rate_limit_info: { status: "rejected", resetsAt: futureResetsAtSec },
|
|
578
|
+
});
|
|
579
|
+
expect(claudeTracker.getRateLimitResetAt()).toBeDefined();
|
|
580
|
+
|
|
581
|
+
// Codex path: processCodexUsageLimitMessage
|
|
582
|
+
const codexTracker = new SessionErrorTracker();
|
|
583
|
+
codexTracker.processCodexUsageLimitMessage(
|
|
584
|
+
"You've hit your usage limit. To get more access now, send a request to your admin or try again at 8:35 PM.",
|
|
585
|
+
);
|
|
586
|
+
expect(codexTracker.getRateLimitResetAt()).toBeDefined();
|
|
587
|
+
|
|
588
|
+
// A tracker that received a Claude event does NOT get cross-contaminated by
|
|
589
|
+
// an independent Codex call on a different instance.
|
|
590
|
+
const iso = claudeTracker.getRateLimitResetAt();
|
|
591
|
+
expect(iso).toBeDefined();
|
|
592
|
+
const ms = new Date(iso!).getTime();
|
|
593
|
+
expect(ms).toBeCloseTo(futureResetsAtSec * 1000, -2); // within 100ms tolerance
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("parseCodexRateLimitResetTime does not interfere with parseRateLimitResetTime fixtures", () => {
|
|
597
|
+
// Claude format: "resets 3pm (UTC)" — must NOT be matched by Codex parser
|
|
598
|
+
expect(parseCodexRateLimitResetTime("resets 3pm (UTC)")).toBeUndefined();
|
|
599
|
+
// Codex format: "try again at 8:35 PM" — must NOT be matched by Claude parser
|
|
600
|
+
expect(parseRateLimitResetTime("try again at 8:35 PM.")).toBeUndefined();
|
|
601
|
+
});
|
|
602
|
+
});
|
|
@@ -8,22 +8,26 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
let server: ReturnType<typeof Bun.serve>;
|
|
11
|
-
|
|
12
|
-
const
|
|
11
|
+
let testUrl: string;
|
|
12
|
+
const nativeFetch = globalThis.fetch.bind(globalThis);
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
type MockResponse = { status: number; body: unknown };
|
|
15
|
+
|
|
16
|
+
const defaultMockResponse: MockResponse = {
|
|
16
17
|
status: 200,
|
|
17
18
|
body: { configs: [] },
|
|
18
19
|
};
|
|
20
|
+
const mockResponsesByAgentId = new Map<string, MockResponse>();
|
|
19
21
|
|
|
20
22
|
beforeAll(() => {
|
|
21
23
|
server = Bun.serve({
|
|
22
|
-
port:
|
|
24
|
+
port: 0,
|
|
23
25
|
fetch(req) {
|
|
24
26
|
const url = new URL(req.url);
|
|
25
27
|
|
|
26
28
|
if (url.pathname === "/api/config/resolved") {
|
|
29
|
+
const agentId = url.searchParams.get("agentId") ?? "";
|
|
30
|
+
const mockResponse = mockResponsesByAgentId.get(agentId) ?? defaultMockResponse;
|
|
27
31
|
return new Response(JSON.stringify(mockResponse.body), {
|
|
28
32
|
status: mockResponse.status,
|
|
29
33
|
headers: { "Content-Type": "application/json" },
|
|
@@ -33,6 +37,7 @@ beforeAll(() => {
|
|
|
33
37
|
return new Response("Not found", { status: 404 });
|
|
34
38
|
},
|
|
35
39
|
});
|
|
40
|
+
testUrl = server.url.toString().replace(/\/$/, "");
|
|
36
41
|
});
|
|
37
42
|
|
|
38
43
|
afterAll(() => {
|
|
@@ -56,7 +61,10 @@ async function fetchResolvedEnv(
|
|
|
56
61
|
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
57
62
|
|
|
58
63
|
const url = `${apiUrl}/api/config/resolved?agentId=${encodeURIComponent(agentId)}&includeSecrets=true`;
|
|
59
|
-
const
|
|
64
|
+
const request = new Request(url, { headers });
|
|
65
|
+
const response = url.startsWith(testUrl)
|
|
66
|
+
? await server.fetch(request)
|
|
67
|
+
: await nativeFetch(request);
|
|
60
68
|
|
|
61
69
|
if (!response.ok) {
|
|
62
70
|
return { ...baseEnv };
|
|
@@ -88,12 +96,13 @@ describe("fetchResolvedEnv", () => {
|
|
|
88
96
|
|
|
89
97
|
test("returns baseEnv when agentId is empty", async () => {
|
|
90
98
|
const baseEnv = { EXISTING: "value" };
|
|
91
|
-
const result = await fetchResolvedEnv(
|
|
99
|
+
const result = await fetchResolvedEnv(testUrl, "key", "", baseEnv);
|
|
92
100
|
expect(result).toEqual({ EXISTING: "value" });
|
|
93
101
|
});
|
|
94
102
|
|
|
95
103
|
test("merges API config over baseEnv", async () => {
|
|
96
|
-
|
|
104
|
+
const agentId = "agent-merge";
|
|
105
|
+
mockResponsesByAgentId.set(agentId, {
|
|
97
106
|
status: 200,
|
|
98
107
|
body: {
|
|
99
108
|
configs: [
|
|
@@ -101,10 +110,10 @@ describe("fetchResolvedEnv", () => {
|
|
|
101
110
|
{ key: "OVERRIDE_VAR", value: "api-wins" },
|
|
102
111
|
],
|
|
103
112
|
},
|
|
104
|
-
};
|
|
113
|
+
});
|
|
105
114
|
|
|
106
115
|
const baseEnv = { EXISTING: "keep", OVERRIDE_VAR: "original" };
|
|
107
|
-
const result = await fetchResolvedEnv(
|
|
116
|
+
const result = await fetchResolvedEnv(testUrl, "key", agentId, baseEnv);
|
|
108
117
|
|
|
109
118
|
expect(result.EXISTING).toBe("keep");
|
|
110
119
|
expect(result.NEW_VAR).toBe("from-api");
|
|
@@ -112,18 +121,20 @@ describe("fetchResolvedEnv", () => {
|
|
|
112
121
|
});
|
|
113
122
|
|
|
114
123
|
test("returns baseEnv when API returns empty configs", async () => {
|
|
115
|
-
|
|
124
|
+
const agentId = "agent-empty";
|
|
125
|
+
mockResponsesByAgentId.set(agentId, { status: 200, body: { configs: [] } });
|
|
116
126
|
|
|
117
127
|
const baseEnv = { EXISTING: "value" };
|
|
118
|
-
const result = await fetchResolvedEnv(
|
|
128
|
+
const result = await fetchResolvedEnv(testUrl, "key", agentId, baseEnv);
|
|
119
129
|
expect(result).toEqual({ EXISTING: "value" });
|
|
120
130
|
});
|
|
121
131
|
|
|
122
132
|
test("returns baseEnv when API returns non-200", async () => {
|
|
123
|
-
|
|
133
|
+
const agentId = "agent-500";
|
|
134
|
+
mockResponsesByAgentId.set(agentId, { status: 500, body: { error: "server error" } });
|
|
124
135
|
|
|
125
136
|
const baseEnv = { EXISTING: "value" };
|
|
126
|
-
const result = await fetchResolvedEnv(
|
|
137
|
+
const result = await fetchResolvedEnv(testUrl, "key", agentId, baseEnv);
|
|
127
138
|
expect(result).toEqual({ EXISTING: "value" });
|
|
128
139
|
});
|
|
129
140
|
|
|
@@ -134,13 +145,14 @@ describe("fetchResolvedEnv", () => {
|
|
|
134
145
|
});
|
|
135
146
|
|
|
136
147
|
test("does not mutate the baseEnv object", async () => {
|
|
137
|
-
|
|
148
|
+
const agentId = "agent-mutation";
|
|
149
|
+
mockResponsesByAgentId.set(agentId, {
|
|
138
150
|
status: 200,
|
|
139
151
|
body: { configs: [{ key: "NEW_VAR", value: "new" }] },
|
|
140
|
-
};
|
|
152
|
+
});
|
|
141
153
|
|
|
142
154
|
const baseEnv = { EXISTING: "value" };
|
|
143
|
-
const result = await fetchResolvedEnv(
|
|
155
|
+
const result = await fetchResolvedEnv(testUrl, "key", agentId, baseEnv);
|
|
144
156
|
|
|
145
157
|
// baseEnv should be untouched
|
|
146
158
|
expect(baseEnv).toEqual({ EXISTING: "value" });
|
|
@@ -148,7 +160,8 @@ describe("fetchResolvedEnv", () => {
|
|
|
148
160
|
});
|
|
149
161
|
|
|
150
162
|
test("handles multiple configs correctly", async () => {
|
|
151
|
-
|
|
163
|
+
const agentId = "agent-multiple";
|
|
164
|
+
mockResponsesByAgentId.set(agentId, {
|
|
152
165
|
status: 200,
|
|
153
166
|
body: {
|
|
154
167
|
configs: [
|
|
@@ -157,9 +170,9 @@ describe("fetchResolvedEnv", () => {
|
|
|
157
170
|
{ key: "VAR_C", value: "c" },
|
|
158
171
|
],
|
|
159
172
|
},
|
|
160
|
-
};
|
|
173
|
+
});
|
|
161
174
|
|
|
162
|
-
const result = await fetchResolvedEnv(
|
|
175
|
+
const result = await fetchResolvedEnv(testUrl, "key", agentId, {});
|
|
163
176
|
expect(result.VAR_A).toBe("a");
|
|
164
177
|
expect(result.VAR_B).toBe("b");
|
|
165
178
|
expect(result.VAR_C).toBe("c");
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type Server,
|
|
21
21
|
type ServerResponse,
|
|
22
22
|
} from "node:http";
|
|
23
|
-
import { closeDb, createUser, getDb, initDb, upsertKv } from "../be/db";
|
|
23
|
+
import { closeDb, createUser, getBudget, getDb, initDb, upsertKv } from "../be/db";
|
|
24
24
|
import { fingerprintApiKey, linkIdentity } from "../be/users";
|
|
25
25
|
import { handleCore } from "../http/core";
|
|
26
26
|
import { handleUsers } from "../http/users";
|
|
@@ -92,6 +92,7 @@ beforeEach(() => {
|
|
|
92
92
|
db.run("DELETE FROM user_external_ids");
|
|
93
93
|
db.run("DELETE FROM user_tokens");
|
|
94
94
|
db.run("DELETE FROM users");
|
|
95
|
+
db.run("DELETE FROM budgets");
|
|
95
96
|
db.run("DELETE FROM kv_entries");
|
|
96
97
|
});
|
|
97
98
|
|
|
@@ -185,6 +186,33 @@ describe("POST /api/users", () => {
|
|
|
185
186
|
});
|
|
186
187
|
|
|
187
188
|
describe("PATCH /api/users/:id", () => {
|
|
189
|
+
test("dailyBudgetUsd mirrors into user-scoped budgets and null removes the mirror", async () => {
|
|
190
|
+
const create = await authedFetch("/api/users", {
|
|
191
|
+
method: "POST",
|
|
192
|
+
body: JSON.stringify({
|
|
193
|
+
name: "Budget Mirror",
|
|
194
|
+
dailyBudgetUsd: 1.25,
|
|
195
|
+
}),
|
|
196
|
+
});
|
|
197
|
+
expect(create.status).toBe(200);
|
|
198
|
+
const { user } = (await create.json()) as { user: { id: string } };
|
|
199
|
+
expect(getBudget("user", user.id)?.dailyBudgetUsd).toBe(1.25);
|
|
200
|
+
|
|
201
|
+
const update = await authedFetch(`/api/users/${user.id}`, {
|
|
202
|
+
method: "PATCH",
|
|
203
|
+
body: JSON.stringify({ dailyBudgetUsd: 2.5 }),
|
|
204
|
+
});
|
|
205
|
+
expect(update.status).toBe(200);
|
|
206
|
+
expect(getBudget("user", user.id)?.dailyBudgetUsd).toBe(2.5);
|
|
207
|
+
|
|
208
|
+
const remove = await authedFetch(`/api/users/${user.id}`, {
|
|
209
|
+
method: "PATCH",
|
|
210
|
+
body: JSON.stringify({ dailyBudgetUsd: null }),
|
|
211
|
+
});
|
|
212
|
+
expect(remove.status).toBe(200);
|
|
213
|
+
expect(getBudget("user", user.id)).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
|
|
188
216
|
test("budget / status / emailAliases diffs each emit the right event types", async () => {
|
|
189
217
|
const u = createUser({
|
|
190
218
|
name: "Patcher",
|