job-application-agent 3.4.2 → 3.6.0
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 +23 -0
- package/installer/src/cli.mjs +8 -1
- package/installer/src/installer.mjs +8 -0
- package/job-application-agent/SKILL.md +32 -2
- package/job-application-agent/capabilities.json +3 -1
- package/job-application-agent/references/ACCOUNTING.md +104 -0
- package/job-application-agent/references/AUTONOMY.md +2 -0
- package/job-application-agent/references/CLOUD_STATE.md +2 -0
- package/job-application-agent/references/FREE_AI.md +39 -0
- package/job-application-agent/references/OUTREACH.md +210 -0
- package/job-application-agent/references/RUNS.md +35 -0
- package/job-application-agent/references/SCHEMAS.md +12 -2
- package/job-application-agent/references/agent-box/README.md +77 -0
- package/job-application-agent/references/agent-box/novnc.service.example +16 -0
- package/job-application-agent/scripts/application-accounting.mjs +246 -0
- package/job-application-agent/scripts/ats/answer-inject.mjs +203 -0
- package/job-application-agent/scripts/ats/submit-adapters.mjs +194 -0
- package/job-application-agent/scripts/attention-questions.mjs +111 -0
- package/job-application-agent/scripts/attention-resume-submit.mjs +450 -0
- package/job-application-agent/scripts/attention-runner-poll.mjs +328 -0
- package/job-application-agent/scripts/captcha-vendor.mjs +328 -0
- package/job-application-agent/scripts/cloud-state-client.mjs +62 -8
- package/job-application-agent/scripts/job-application.mjs +243 -27
- package/job-application-agent/scripts/novnc-display-guard.mjs +300 -0
- package/job-application-agent/scripts/outreach-cli.mjs +95 -0
- package/job-application-agent/scripts/outreach-domain.mjs +287 -0
- package/job-application-agent/scripts/outreach-store.mjs +72 -0
- package/job-application-agent/scripts/session-binding.mjs +474 -0
- package/job-application-agent/scripts/version.mjs +1 -1
- package/job-application-agent/tests/accounting-cli.test.mjs +86 -0
- package/job-application-agent/tests/accounting-cloud-client.test.mjs +183 -0
- package/job-application-agent/tests/accounting-retry-cli.test.mjs +96 -0
- package/job-application-agent/tests/accounting-source-race.test.mjs +109 -0
- package/job-application-agent/tests/answer-inject-captcha.test.mjs +169 -0
- package/job-application-agent/tests/application-accounting.test.mjs +315 -0
- package/job-application-agent/tests/attention-resume-submit.test.mjs +119 -0
- package/job-application-agent/tests/attention-runner-poll.test.mjs +135 -0
- package/job-application-agent/tests/fixtures/outreach.mjs +22 -0
- package/job-application-agent/tests/job-application.test.mjs +5 -0
- package/job-application-agent/tests/novnc-display-guard.test.mjs +50 -0
- package/job-application-agent/tests/outreach-cli.test.mjs +73 -0
- package/job-application-agent/tests/outreach.test.mjs +178 -0
- package/job-application-agent/tests/privacy-audit.test.mjs +2 -0
- package/job-application-agent/tests/review-cadence.test.mjs +66 -0
- package/job-application-agent/tests/session-binding.test.mjs +102 -0
- package/job-application-agent/tests/skill-contract.test.mjs +25 -0
- package/job-application-agent/tests/workflow-state.test.mjs +30 -3
- package/package.json +6 -3
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* GCP / Antigravity hosted-runner poll loop for attention resume signals.
|
|
4
|
+
*
|
|
5
|
+
* Polls GET /api/internal/attention-signals/:id with ATTENTION_NOTIFY_SECRET.
|
|
6
|
+
* Exit codes are stable for shell loops / Antigravity task graphs:
|
|
7
|
+
*
|
|
8
|
+
* 0 resume_requested — renew lease, re-inspect ATS, submit only on visible confirm
|
|
9
|
+
* 10 skipped — resolve attention, no submit, continue round
|
|
10
|
+
* 11 aborted — release lease, end round
|
|
11
|
+
* 20 timeout — still waiting when --timeout elapsed
|
|
12
|
+
* 1 hard error — missing env, HTTP/auth failure, invalid args
|
|
13
|
+
*
|
|
14
|
+
* Env:
|
|
15
|
+
* ATTENTION_NOTIFY_SECRET (required) Bearer for internal poll
|
|
16
|
+
* PUBLIC_SITE_URL site origin, default https://jobappagent.com
|
|
17
|
+
* ATTENTION_NOTIFY_URL optional; origin derived if PUBLIC_SITE_URL unset
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* node scripts/attention-runner-poll.mjs --attention-id attention-…
|
|
21
|
+
* node scripts/attention-runner-poll.mjs --attention-id attention-… --interval 5 --timeout 3600
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { pathToFileURL } from "node:url";
|
|
25
|
+
|
|
26
|
+
export const EXIT = Object.freeze({
|
|
27
|
+
RESUME: 0,
|
|
28
|
+
ERROR: 1,
|
|
29
|
+
SKIPPED: 10,
|
|
30
|
+
ABORTED: 11,
|
|
31
|
+
TIMEOUT: 20,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string[]} argv
|
|
36
|
+
*/
|
|
37
|
+
export function parseArgs(argv) {
|
|
38
|
+
const args = { attentionId: "", intervalSec: 5, timeoutSec: 3600, once: false };
|
|
39
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
40
|
+
const flag = argv[i];
|
|
41
|
+
const next = argv[i + 1];
|
|
42
|
+
if (flag === "--attention-id" || flag === "--id") {
|
|
43
|
+
args.attentionId = String(next ?? "").trim();
|
|
44
|
+
i += 1;
|
|
45
|
+
} else if (flag === "--interval") {
|
|
46
|
+
args.intervalSec = Number(next);
|
|
47
|
+
i += 1;
|
|
48
|
+
} else if (flag === "--timeout") {
|
|
49
|
+
args.timeoutSec = Number(next);
|
|
50
|
+
i += 1;
|
|
51
|
+
} else if (flag === "--once") {
|
|
52
|
+
args.once = true;
|
|
53
|
+
} else if (flag === "--help" || flag === "-h") {
|
|
54
|
+
args.help = true;
|
|
55
|
+
} else if (flag.startsWith("-")) {
|
|
56
|
+
throw new Error(`Unknown flag: ${flag}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return args;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {NodeJS.ProcessEnv} env
|
|
64
|
+
*/
|
|
65
|
+
export function resolvePollConfig(env = process.env) {
|
|
66
|
+
const secret = String(env.ATTENTION_NOTIFY_SECRET ?? "").trim();
|
|
67
|
+
let siteUrl = String(env.PUBLIC_SITE_URL ?? env.ATTENTION_SITE_URL ?? "").trim();
|
|
68
|
+
if (!siteUrl && env.ATTENTION_NOTIFY_URL) {
|
|
69
|
+
try {
|
|
70
|
+
siteUrl = new URL(env.ATTENTION_NOTIFY_URL).origin;
|
|
71
|
+
} catch {
|
|
72
|
+
siteUrl = "";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!siteUrl) siteUrl = "https://jobappagent.com";
|
|
76
|
+
return { secret, siteUrl };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} siteUrl
|
|
81
|
+
* @param {string} attentionId
|
|
82
|
+
*/
|
|
83
|
+
export function buildSignalPollUrl(siteUrl, attentionId) {
|
|
84
|
+
const origin = new URL(siteUrl).origin;
|
|
85
|
+
return new URL(`/api/internal/attention-signals/${encodeURIComponent(attentionId)}`, origin).toString();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {object} poll
|
|
90
|
+
*/
|
|
91
|
+
export function interpretPoll(poll) {
|
|
92
|
+
if (!poll || typeof poll !== "object") {
|
|
93
|
+
return { done: false, waiting: true, action: "wait", message: "Empty poll body; keep waiting." };
|
|
94
|
+
}
|
|
95
|
+
if (poll.resumeRequested || poll.signal === "resume_requested") {
|
|
96
|
+
return {
|
|
97
|
+
done: true,
|
|
98
|
+
exitCode: EXIT.RESUME,
|
|
99
|
+
signal: "resume_requested",
|
|
100
|
+
action: "resume",
|
|
101
|
+
message: [
|
|
102
|
+
"Signal: resume_requested",
|
|
103
|
+
"Next: renew cloud lease → load session binding (same tab / DISPLAY=:99 / VNC 5900) →",
|
|
104
|
+
"re-inspect the filled ATS page → submit if possible → wait for visible confirmation →",
|
|
105
|
+
"ledger intent-confirm. filled ≠ applied.",
|
|
106
|
+
"Helper: node scripts/attention-resume-submit.mjs --attention-id … --checklist",
|
|
107
|
+
"If unsure or still blocked, re-open attention honestly.",
|
|
108
|
+
].join(" "),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (poll.skipped || poll.signal === "skipped") {
|
|
112
|
+
return {
|
|
113
|
+
done: true,
|
|
114
|
+
exitCode: EXIT.SKIPPED,
|
|
115
|
+
signal: "skipped",
|
|
116
|
+
action: "skip",
|
|
117
|
+
message: [
|
|
118
|
+
"Signal: skipped",
|
|
119
|
+
"Next: attention resolve (no submit) → continue the round on other roles.",
|
|
120
|
+
].join(" "),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (poll.aborted || poll.signal === "aborted") {
|
|
124
|
+
return {
|
|
125
|
+
done: true,
|
|
126
|
+
exitCode: EXIT.ABORTED,
|
|
127
|
+
signal: "aborted",
|
|
128
|
+
action: "abort",
|
|
129
|
+
message: [
|
|
130
|
+
"Signal: aborted",
|
|
131
|
+
"Next: cloud lease-release → end the round. Do not submit.",
|
|
132
|
+
].join(" "),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
done: false,
|
|
137
|
+
waiting: true,
|
|
138
|
+
action: "wait",
|
|
139
|
+
message: "No candidate signal yet (pending/null). Keep polling while lease held.",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {{
|
|
145
|
+
* attentionId: string,
|
|
146
|
+
* siteUrl: string,
|
|
147
|
+
* secret: string,
|
|
148
|
+
* intervalSec?: number,
|
|
149
|
+
* timeoutSec?: number,
|
|
150
|
+
* once?: boolean,
|
|
151
|
+
* fetchImpl?: typeof fetch,
|
|
152
|
+
* sleep?: (ms: number) => Promise<void>,
|
|
153
|
+
* now?: () => number,
|
|
154
|
+
* log?: (line: string) => void,
|
|
155
|
+
* }} options
|
|
156
|
+
*/
|
|
157
|
+
export async function pollAttentionSignal(options) {
|
|
158
|
+
const {
|
|
159
|
+
attentionId,
|
|
160
|
+
siteUrl,
|
|
161
|
+
secret,
|
|
162
|
+
intervalSec = 5,
|
|
163
|
+
timeoutSec = 3600,
|
|
164
|
+
once = false,
|
|
165
|
+
fetchImpl = fetch,
|
|
166
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
167
|
+
now = () => Date.now(),
|
|
168
|
+
log = console.log,
|
|
169
|
+
} = options;
|
|
170
|
+
|
|
171
|
+
if (!attentionId) return { ok: false, exitCode: EXIT.ERROR, error: "attention_id_required" };
|
|
172
|
+
if (!secret) return { ok: false, exitCode: EXIT.ERROR, error: "ATTENTION_NOTIFY_SECRET missing" };
|
|
173
|
+
|
|
174
|
+
let pollUrl;
|
|
175
|
+
try {
|
|
176
|
+
pollUrl = buildSignalPollUrl(siteUrl, attentionId);
|
|
177
|
+
} catch {
|
|
178
|
+
return { ok: false, exitCode: EXIT.ERROR, error: "site_url_invalid" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const started = now();
|
|
182
|
+
const deadline = started + Math.max(1, timeoutSec) * 1000;
|
|
183
|
+
const intervalMs = Math.max(1, intervalSec) * 1000;
|
|
184
|
+
|
|
185
|
+
log(`[attention-poll] watching ${attentionId}`);
|
|
186
|
+
log(`[attention-poll] GET ${pollUrl} every ${intervalSec}s (timeout ${timeoutSec}s)`);
|
|
187
|
+
|
|
188
|
+
while (true) {
|
|
189
|
+
let response;
|
|
190
|
+
try {
|
|
191
|
+
response = await fetchImpl(pollUrl, {
|
|
192
|
+
method: "GET",
|
|
193
|
+
headers: {
|
|
194
|
+
authorization: `Bearer ${secret}`,
|
|
195
|
+
accept: "application/json",
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
} catch (error) {
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
exitCode: EXIT.ERROR,
|
|
202
|
+
error: `poll_request_failed: ${error instanceof Error ? error.message : "unknown"}`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (response.status === 401) {
|
|
207
|
+
return { ok: false, exitCode: EXIT.ERROR, error: "unauthorized — check ATTENTION_NOTIFY_SECRET" };
|
|
208
|
+
}
|
|
209
|
+
if (!response.ok) {
|
|
210
|
+
const body = await response.text().catch(() => "");
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
exitCode: EXIT.ERROR,
|
|
214
|
+
error: `poll_http_${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const poll = await response.json().catch(() => null);
|
|
219
|
+
const interpreted = interpretPoll(poll);
|
|
220
|
+
if (interpreted.done) {
|
|
221
|
+
log(`[attention-poll] ${interpreted.message}`);
|
|
222
|
+
return {
|
|
223
|
+
ok: true,
|
|
224
|
+
exitCode: interpreted.exitCode,
|
|
225
|
+
signal: interpreted.signal,
|
|
226
|
+
action: interpreted.action,
|
|
227
|
+
poll,
|
|
228
|
+
message: interpreted.message,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
log(`[attention-poll] waiting… (${poll?.signal ?? "null"})`);
|
|
233
|
+
if (once) {
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
exitCode: EXIT.TIMEOUT,
|
|
237
|
+
action: "wait",
|
|
238
|
+
poll,
|
|
239
|
+
message: "No signal on --once poll.",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (now() >= deadline) {
|
|
243
|
+
log("[attention-poll] timeout — still no resume/skip/abort signal");
|
|
244
|
+
return {
|
|
245
|
+
ok: false,
|
|
246
|
+
exitCode: EXIT.TIMEOUT,
|
|
247
|
+
error: "timeout",
|
|
248
|
+
message: "Timeout waiting for candidate signal. Renew lease or re-notify.",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
await sleep(intervalMs);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function printHelp() {
|
|
256
|
+
console.log(`Usage: node scripts/attention-runner-poll.mjs --attention-id <id> [options]
|
|
257
|
+
|
|
258
|
+
Options:
|
|
259
|
+
--interval <sec> Poll interval (default 5)
|
|
260
|
+
--timeout <sec> Max wait (default 3600)
|
|
261
|
+
--once Single poll then exit 20 if still waiting
|
|
262
|
+
|
|
263
|
+
Exit codes:
|
|
264
|
+
0 resume_requested
|
|
265
|
+
10 skipped
|
|
266
|
+
11 aborted
|
|
267
|
+
20 timeout / still waiting
|
|
268
|
+
1 error
|
|
269
|
+
|
|
270
|
+
Env: ATTENTION_NOTIFY_SECRET, PUBLIC_SITE_URL (or ATTENTION_NOTIFY_URL origin)
|
|
271
|
+
`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function main(argv = process.argv.slice(2)) {
|
|
275
|
+
let args;
|
|
276
|
+
try {
|
|
277
|
+
args = parseArgs(argv);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
console.error(`[attention-poll] ${error instanceof Error ? error.message : error}`);
|
|
280
|
+
process.exitCode = EXIT.ERROR;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (args.help) {
|
|
284
|
+
printHelp();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (!args.attentionId) {
|
|
288
|
+
console.error("[attention-poll] --attention-id is required");
|
|
289
|
+
printHelp();
|
|
290
|
+
process.exitCode = EXIT.ERROR;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (!Number.isFinite(args.intervalSec) || args.intervalSec <= 0) {
|
|
294
|
+
console.error("[attention-poll] --interval must be a positive number");
|
|
295
|
+
process.exitCode = EXIT.ERROR;
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (!Number.isFinite(args.timeoutSec) || args.timeoutSec <= 0) {
|
|
299
|
+
console.error("[attention-poll] --timeout must be a positive number");
|
|
300
|
+
process.exitCode = EXIT.ERROR;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const { secret, siteUrl } = resolvePollConfig(process.env);
|
|
305
|
+
const result = await pollAttentionSignal({
|
|
306
|
+
attentionId: args.attentionId,
|
|
307
|
+
siteUrl,
|
|
308
|
+
secret,
|
|
309
|
+
intervalSec: args.intervalSec,
|
|
310
|
+
timeoutSec: args.timeoutSec,
|
|
311
|
+
once: args.once,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
if (!result.ok && result.error) {
|
|
315
|
+
console.error(`[attention-poll] ${result.error}`);
|
|
316
|
+
}
|
|
317
|
+
process.exitCode = result.exitCode;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const isDirect = import.meta.url === pathToFileURL(process.argv[1] ?? "").href
|
|
321
|
+
|| process.argv[1]?.endsWith("attention-runner-poll.mjs");
|
|
322
|
+
|
|
323
|
+
if (isDirect) {
|
|
324
|
+
main().catch((error) => {
|
|
325
|
+
console.error(`[attention-poll] ${error instanceof Error ? error.message : error}`);
|
|
326
|
+
process.exitCode = EXIT.ERROR;
|
|
327
|
+
});
|
|
328
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P2 CAPTCHA vendor scaffolding — gated Off by default.
|
|
3
|
+
*
|
|
4
|
+
* CAPTCHA_VENDOR=off|capsolver|2captcha (default off)
|
|
5
|
+
* Never call vendors unless vendor≠off AND api key present AND buyer opt-in.
|
|
6
|
+
* Fail closed to attention / live panel (complete-captcha).
|
|
7
|
+
*
|
|
8
|
+
* No cookie/session theft: solvers get only public page URL + sitekey + type.
|
|
9
|
+
* Solving CAPTCHA is never "applied" — submit + visible confirm still required.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const CAPTCHA_VENDORS = Object.freeze(["off", "capsolver", "2captcha"]);
|
|
13
|
+
|
|
14
|
+
export const CAPTCHA_FRICTION = Object.freeze({
|
|
15
|
+
attempt: "captcha_vendor_attempt",
|
|
16
|
+
success: "captcha_vendor_success",
|
|
17
|
+
fail: "captcha_vendor_fail",
|
|
18
|
+
unsupported: "captcha_vendor_unsupported",
|
|
19
|
+
cap_hit: "captcha_vendor_cap_hit",
|
|
20
|
+
skipped_off: "captcha_vendor_skipped_off",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
25
|
+
*/
|
|
26
|
+
export function resolveCaptchaVendorConfig(env = process.env) {
|
|
27
|
+
const vendorRaw = String(env.CAPTCHA_VENDOR ?? "off").trim().toLowerCase();
|
|
28
|
+
const vendor = CAPTCHA_VENDORS.includes(vendorRaw) ? vendorRaw : "off";
|
|
29
|
+
const apiKey = String(env.CAPTCHA_VENDOR_API_KEY ?? "").trim();
|
|
30
|
+
const spendCapUsd = Number(env.CAPTCHA_SPEND_CAP_USD_MONTH ?? 5);
|
|
31
|
+
const spendMonthUsd = Number(env.CAPTCHA_SPEND_MONTH_USD ?? 0);
|
|
32
|
+
const buyerOptIn = String(env.CAPTCHA_BUYER_OPT_IN ?? env.CAPTCHA_ASSIST ?? "")
|
|
33
|
+
.trim()
|
|
34
|
+
.toLowerCase();
|
|
35
|
+
const captchaAssist = buyerOptIn === "on" || buyerOptIn === "true" || buyerOptIn === "1";
|
|
36
|
+
return {
|
|
37
|
+
vendor,
|
|
38
|
+
apiKey,
|
|
39
|
+
spendCapUsd: Number.isFinite(spendCapUsd) && spendCapUsd > 0 ? spendCapUsd : 5,
|
|
40
|
+
spendMonthUsd: Number.isFinite(spendMonthUsd) && spendMonthUsd >= 0 ? spendMonthUsd : 0,
|
|
41
|
+
captchaAssist,
|
|
42
|
+
enabled: vendor !== "off" && Boolean(apiKey) && captchaAssist,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Hard stop when monthly spend would exceed the cap.
|
|
48
|
+
* @param {{ spendMonthUsd?: number, spendCapUsd?: number, additionalUsd?: number }} input
|
|
49
|
+
*/
|
|
50
|
+
export function checkCaptchaSpendCap(input = {}) {
|
|
51
|
+
const spent = Number(input.spendMonthUsd ?? 0);
|
|
52
|
+
const cap = Number(input.spendCapUsd ?? 5);
|
|
53
|
+
const additional = Number(input.additionalUsd ?? 0);
|
|
54
|
+
const safeSpent = Number.isFinite(spent) ? Math.max(0, spent) : 0;
|
|
55
|
+
const safeCap = Number.isFinite(cap) && cap > 0 ? cap : 5;
|
|
56
|
+
const safeAdditional = Number.isFinite(additional) ? Math.max(0, additional) : 0;
|
|
57
|
+
if (safeSpent + safeAdditional > safeCap) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
error: "spend_cap_hit",
|
|
61
|
+
friction: CAPTCHA_FRICTION.cap_hit,
|
|
62
|
+
message: "CAPTCHA assist spend cap reached. Fall back to live panel (complete-captcha).",
|
|
63
|
+
spendMonthUsd: safeSpent,
|
|
64
|
+
spendCapUsd: safeCap,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
spendMonthUsd: safeSpent,
|
|
70
|
+
spendCapUsd: safeCap,
|
|
71
|
+
remainingUsd: Math.max(0, safeCap - safeSpent),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Detect common challenge widgets from a page snapshot (no network).
|
|
77
|
+
* @param {{
|
|
78
|
+
* pageUrl?: string,
|
|
79
|
+
* pageHtml?: string,
|
|
80
|
+
* pageText?: string,
|
|
81
|
+
* sitekey?: string,
|
|
82
|
+
* challengeType?: string,
|
|
83
|
+
* matchedBlockerSelectors?: string[],
|
|
84
|
+
* }} snapshot
|
|
85
|
+
*/
|
|
86
|
+
export function detectChallenge(snapshot = {}) {
|
|
87
|
+
const html = String(snapshot.pageHtml ?? snapshot.pageText ?? "");
|
|
88
|
+
const selectors = Array.isArray(snapshot.matchedBlockerSelectors)
|
|
89
|
+
? snapshot.matchedBlockerSelectors.join(" ")
|
|
90
|
+
: "";
|
|
91
|
+
const haystack = `${html}\n${selectors}`;
|
|
92
|
+
const explicit = String(snapshot.challengeType ?? "").trim().toLowerCase();
|
|
93
|
+
|
|
94
|
+
let type = "unknown";
|
|
95
|
+
if (explicit) type = explicit;
|
|
96
|
+
else if (/cf-turnstile|turnstile/i.test(haystack)) type = "turnstile";
|
|
97
|
+
else if (/recaptcha\/enterprise|google\.com\/recaptcha\/enterprise/i.test(haystack)) type = "recaptcha_v2";
|
|
98
|
+
else if (/recaptcha|g-recaptcha|grecaptcha/i.test(haystack)) type = "recaptcha_v2";
|
|
99
|
+
else if (/hcaptcha/i.test(haystack)) type = "hcaptcha";
|
|
100
|
+
else if (/captcha/i.test(haystack)) type = "unknown";
|
|
101
|
+
else {
|
|
102
|
+
return { present: false, type: null, sitekey: null, pageurl: snapshot.pageUrl ?? null };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const sitekey = String(
|
|
106
|
+
snapshot.sitekey
|
|
107
|
+
?? haystack.match(/data-sitekey=["']([^"']+)["']/i)?.[1]
|
|
108
|
+
?? "",
|
|
109
|
+
).trim() || null;
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
present: true,
|
|
113
|
+
type,
|
|
114
|
+
sitekey,
|
|
115
|
+
pageurl: snapshot.pageUrl ?? null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* CapSolver adapter stub — refuses unless fully enabled.
|
|
121
|
+
* @param {object} task
|
|
122
|
+
* @param {ReturnType<typeof resolveCaptchaVendorConfig>} config
|
|
123
|
+
* @param {{ fetchImpl?: typeof fetch }} [options]
|
|
124
|
+
*/
|
|
125
|
+
export async function capsolverCreateTask(task, config, options = {}) {
|
|
126
|
+
if (config.vendor !== "capsolver") {
|
|
127
|
+
return { ok: false, error: "vendor_mismatch", friction: CAPTCHA_FRICTION.skipped_off };
|
|
128
|
+
}
|
|
129
|
+
if (!config.enabled) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
error: config.apiKey ? "buyer_opt_in_required" : "api_key_missing",
|
|
133
|
+
friction: CAPTCHA_FRICTION.fail,
|
|
134
|
+
message: "CapSolver refuse: vendor enabled only with API key + buyer captchaAssist=on.",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// Scaffold only — no real network in this slice.
|
|
138
|
+
void options.fetchImpl;
|
|
139
|
+
void task;
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
error: "adapter_stub",
|
|
143
|
+
friction: CAPTCHA_FRICTION.unsupported,
|
|
144
|
+
message: "CapSolver adapter is scaffolded Off. Spike implementation gated until explicit go.",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 2Captcha adapter stub — refuses unless fully enabled.
|
|
150
|
+
* @param {object} task
|
|
151
|
+
* @param {ReturnType<typeof resolveCaptchaVendorConfig>} config
|
|
152
|
+
* @param {{ fetchImpl?: typeof fetch }} [options]
|
|
153
|
+
*/
|
|
154
|
+
export async function twocaptchaCreateTask(task, config, options = {}) {
|
|
155
|
+
if (config.vendor !== "2captcha") {
|
|
156
|
+
return { ok: false, error: "vendor_mismatch", friction: CAPTCHA_FRICTION.skipped_off };
|
|
157
|
+
}
|
|
158
|
+
if (!config.enabled) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
error: config.apiKey ? "buyer_opt_in_required" : "api_key_missing",
|
|
162
|
+
friction: CAPTCHA_FRICTION.fail,
|
|
163
|
+
message: "2Captcha refuse: vendor enabled only with API key + buyer captchaAssist=on.",
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
void options.fetchImpl;
|
|
167
|
+
void task;
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
error: "adapter_stub",
|
|
171
|
+
friction: CAPTCHA_FRICTION.unsupported,
|
|
172
|
+
message: "2Captcha adapter is scaffolded Off. Spike implementation gated until explicit go.",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Poll stub — always fail-closed (no network).
|
|
178
|
+
* @param {string} taskId
|
|
179
|
+
* @param {ReturnType<typeof resolveCaptchaVendorConfig>} config
|
|
180
|
+
*/
|
|
181
|
+
export async function pollCaptchaTask(taskId, config) {
|
|
182
|
+
void taskId;
|
|
183
|
+
if (!config.enabled) {
|
|
184
|
+
return { ok: false, error: "vendor_off", friction: CAPTCHA_FRICTION.skipped_off };
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
error: "adapter_stub",
|
|
189
|
+
friction: CAPTCHA_FRICTION.unsupported,
|
|
190
|
+
message: "CAPTCHA poll stub — no vendor calls until spike is approved.",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Token inject guidance — never runs when vendor off.
|
|
196
|
+
* @param {{ token?: string, type?: string }} result
|
|
197
|
+
* @param {ReturnType<typeof resolveCaptchaVendorConfig>} config
|
|
198
|
+
*/
|
|
199
|
+
export function injectCaptchaToken(result, config) {
|
|
200
|
+
if (!config.enabled) {
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
error: "vendor_off",
|
|
204
|
+
friction: CAPTCHA_FRICTION.skipped_off,
|
|
205
|
+
message: "CAPTCHA vendor Off — use attention complete-captcha / live panel.",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (!result?.token) {
|
|
209
|
+
return { ok: false, error: "token_missing", friction: CAPTCHA_FRICTION.fail };
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
error: "adapter_stub",
|
|
214
|
+
friction: CAPTCHA_FRICTION.unsupported,
|
|
215
|
+
message: "Token inject stubbed. Fail closed to live panel.",
|
|
216
|
+
guidance: {
|
|
217
|
+
note: "When implemented: set textarea/input for g-recaptcha-response / cf-turnstile-response; never store token in cloud state.",
|
|
218
|
+
type: result.type ?? null,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Single call-site helper for resume / re-inspect.
|
|
225
|
+
* When Off (default): identical to today — needs human complete-captcha.
|
|
226
|
+
*
|
|
227
|
+
* @param {{
|
|
228
|
+
* snapshot?: object,
|
|
229
|
+
* env?: NodeJS.ProcessEnv,
|
|
230
|
+
* fetchImpl?: typeof fetch,
|
|
231
|
+
* }} [input]
|
|
232
|
+
*/
|
|
233
|
+
export async function tryCaptchaVendorAssist(input = {}) {
|
|
234
|
+
const config = resolveCaptchaVendorConfig(input.env ?? process.env);
|
|
235
|
+
const challenge = detectChallenge(input.snapshot ?? {});
|
|
236
|
+
|
|
237
|
+
if (!challenge.present) {
|
|
238
|
+
return { ok: true, assisted: false, reason: "no_challenge", challenge };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (config.vendor === "off") {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
assisted: false,
|
|
245
|
+
reason: "vendor_off",
|
|
246
|
+
friction: CAPTCHA_FRICTION.skipped_off,
|
|
247
|
+
challenge,
|
|
248
|
+
fallback: "complete-captcha",
|
|
249
|
+
message: "CAPTCHA_VENDOR=off (default). Use live panel / attention complete-captcha.",
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!config.apiKey) {
|
|
254
|
+
return {
|
|
255
|
+
ok: false,
|
|
256
|
+
assisted: false,
|
|
257
|
+
reason: "api_key_missing",
|
|
258
|
+
friction: CAPTCHA_FRICTION.fail,
|
|
259
|
+
challenge,
|
|
260
|
+
fallback: "complete-captcha",
|
|
261
|
+
message: "CAPTCHA vendor key missing — fail closed to human.",
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!config.captchaAssist) {
|
|
266
|
+
return {
|
|
267
|
+
ok: false,
|
|
268
|
+
assisted: false,
|
|
269
|
+
reason: "buyer_opt_in_required",
|
|
270
|
+
friction: CAPTCHA_FRICTION.fail,
|
|
271
|
+
challenge,
|
|
272
|
+
fallback: "complete-captcha",
|
|
273
|
+
message: "Buyer captchaAssist is off — fail closed to human.",
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const cap = checkCaptchaSpendCap({
|
|
278
|
+
spendMonthUsd: config.spendMonthUsd,
|
|
279
|
+
spendCapUsd: config.spendCapUsd,
|
|
280
|
+
additionalUsd: 0.01,
|
|
281
|
+
});
|
|
282
|
+
if (!cap.ok) {
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
assisted: false,
|
|
286
|
+
reason: "spend_cap_hit",
|
|
287
|
+
friction: CAPTCHA_FRICTION.cap_hit,
|
|
288
|
+
challenge,
|
|
289
|
+
fallback: "complete-captcha",
|
|
290
|
+
message: cap.message,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const supported = new Set(["recaptcha_v2", "turnstile"]);
|
|
295
|
+
if (!supported.has(challenge.type)) {
|
|
296
|
+
return {
|
|
297
|
+
ok: false,
|
|
298
|
+
assisted: false,
|
|
299
|
+
reason: "unsupported_type",
|
|
300
|
+
friction: CAPTCHA_FRICTION.unsupported,
|
|
301
|
+
challenge,
|
|
302
|
+
fallback: "complete-captcha",
|
|
303
|
+
message: `Unsupported challenge type: ${challenge.type}. Fall back to live panel.`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const task = {
|
|
308
|
+
type: challenge.type,
|
|
309
|
+
sitekey: challenge.sitekey,
|
|
310
|
+
pageurl: challenge.pageurl,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const created = config.vendor === "2captcha"
|
|
314
|
+
? await twocaptchaCreateTask(task, config, { fetchImpl: input.fetchImpl })
|
|
315
|
+
: await capsolverCreateTask(task, config, { fetchImpl: input.fetchImpl });
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
ok: false,
|
|
319
|
+
assisted: false,
|
|
320
|
+
reason: created.error ?? "adapter_stub",
|
|
321
|
+
friction: created.friction ?? CAPTCHA_FRICTION.fail,
|
|
322
|
+
challenge,
|
|
323
|
+
fallback: "complete-captcha",
|
|
324
|
+
message: created.message ?? "CAPTCHA vendor assist failed closed.",
|
|
325
|
+
// Network was not used by stubs; keep this explicit for tests.
|
|
326
|
+
networkCalled: false,
|
|
327
|
+
};
|
|
328
|
+
}
|