job-application-agent 3.4.0 → 3.4.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/README.md +40 -4
- package/installer/src/installer.mjs +21 -4
- package/job-application-agent/SKILL.md +22 -14
- package/job-application-agent/capabilities.json +5 -0
- package/job-application-agent/references/CLOUD_STATE.md +19 -0
- package/job-application-agent/references/RUNS.md +6 -0
- package/job-application-agent/references/SCHEMAS.md +6 -2
- package/job-application-agent/references/VPS_CLIENTS.md +33 -0
- package/job-application-agent/scripts/cloud-state-client.mjs +384 -0
- package/job-application-agent/scripts/job-application.mjs +126 -9
- package/job-application-agent/scripts/version.mjs +1 -1
- package/job-application-agent/tests/cloud-state-client.test.mjs +105 -0
- package/job-application-agent/tests/job-application.test.mjs +19 -0
- package/job-application-agent/tests/linux-secret-service.integration.test.mjs +1 -0
- package/job-application-agent/tests/workflow-state.test.mjs +1 -1
- package/package.json +10 -4
package/README.md
CHANGED
|
@@ -9,22 +9,42 @@
|
|
|
9
9
|
[](LICENSE)
|
|
10
10
|
[](job-application-agent/SKILL.md)
|
|
11
11
|
|
|
12
|
-
[Get started](#-get-started) · [Safety](#-safety) · [Privacy](#-privacy) · [Dashboard](https://job-application-agent-telemetry.varora1406.workers.dev/) · [Security](SECURITY.md)
|
|
12
|
+
[Get started](#-get-started) · [Privacy in 30 seconds](#-privacy-in-30-seconds) · [Safety](#-safety) · [Privacy](#-privacy) · [Where to find it](#-where-to-find-it) · [Dashboard](https://job-application-agent-telemetry.varora1406.workers.dev/) · [Security](SECURITY.md)
|
|
13
13
|
|
|
14
14
|
</div>
|
|
15
15
|
|
|
16
|
+
```bash
|
|
17
|
+
npx job-application-agent@latest install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx skills add vaibhavarora14/job-application-agent
|
|
22
|
+
```
|
|
23
|
+
|
|
16
24
|
---
|
|
17
25
|
|
|
18
26
|
Job Application Agent is an [Agent Skill](https://agentskills.io/specification) that helps a coding agent discover, evaluate, complete, and track your own job applications. It uses one verified résumé, checks eligibility and duplicates, and records only confirmed submissions.
|
|
19
27
|
|
|
28
|
+
## 🔒 Privacy in 30 seconds
|
|
29
|
+
|
|
30
|
+
- OS credential store for profile
|
|
31
|
+
- One verified résumé; refuse unverifiable claims
|
|
32
|
+
- Never reads cookies/session files; pauses for SSO/MFA/CAPTCHA/legal
|
|
33
|
+
- Only records confirmed submissions (filled ≠ applied)
|
|
34
|
+
- Analytics/community sharing opt-out via CLI
|
|
35
|
+
|
|
20
36
|
## 🚀 Get started
|
|
21
37
|
|
|
22
|
-
Install it:
|
|
38
|
+
Install it with either:
|
|
23
39
|
|
|
24
40
|
```bash
|
|
25
41
|
npx job-application-agent@latest install
|
|
26
42
|
```
|
|
27
43
|
|
|
44
|
+
```bash
|
|
45
|
+
npx skills add vaibhavarora14/job-application-agent
|
|
46
|
+
```
|
|
47
|
+
|
|
28
48
|
Then tell your coding agent:
|
|
29
49
|
|
|
30
50
|
```text
|
|
@@ -49,6 +69,8 @@ On Linux, profile storage uses the Secret Service via the `secret-tool` CLI. Ins
|
|
|
49
69
|
|
|
50
70
|
Unlike macOS Keychain or Windows Credential Manager, the Linux Secret Service has no always-running system daemon: a keyring daemon (GNOME Keyring, KWallet, or similar) must be running in the user session for `secret-tool` to store or read the profile. On a desktop login this is normally already the case; on headless servers, containers, or SSH-only sessions, start one explicitly (e.g. `gnome-keyring-daemon --unlock --components=secrets`) before first use.
|
|
51
71
|
|
|
72
|
+
For one person's agents across several trusted hosts, an optional private Cloudflare D1 backend with R2 (or a private Workers KV blob fallback) shares profile, résumé, application, outcome, round, answer, and attention state. Each host receives a separate revocable credential, and one renewable lease ensures only one host submits applications at a time. See [`CLOUD_STATE.md`](job-application-agent/references/CLOUD_STATE.md).
|
|
73
|
+
|
|
52
74
|
## ✨ What it does
|
|
53
75
|
|
|
54
76
|
| Stage | Behavior |
|
|
@@ -114,15 +136,17 @@ Rounds require recorded attempts across at least three distinct discovery source
|
|
|
114
136
|
|
|
115
137
|
| Data | Where it stays |
|
|
116
138
|
|---|---|
|
|
117
|
-
| Profile |
|
|
139
|
+
| Profile | OS credential store, or private D1 with an owner-only local cache |
|
|
118
140
|
| Analytics name and email (unless opted out) | Private PostHog events after disclosure; local sharing preference in owner-only state |
|
|
119
|
-
| Résumé and ledgers | Owner-only local state
|
|
141
|
+
| Résumé and ledgers | Owner-only local state, or private D1 plus private blob storage when configured |
|
|
120
142
|
| Browser login | Existing browser session |
|
|
121
143
|
| Community-sharing preference and delivery receipts | Owner-only local state directory |
|
|
122
144
|
| Skill code | Version-controlled installation directory |
|
|
123
145
|
|
|
124
146
|
Candidate data, résumés, application history, credentials, and browser sessions are never committed to this repository.
|
|
125
147
|
|
|
148
|
+
Private cloud state is opt-in and isolated from public analytics/community services. Client tokens remain in owner-only host configuration, only token hashes are stored server-side, and browser/Gmail credentials never enter the backend.
|
|
149
|
+
|
|
126
150
|
Structured usage analytics and name/email sharing are enabled by default for support and product improvement. After a disclosure command with no identity transmission, subsequent commands include the name and email explicitly saved in the candidate profile in the maintainer's private PostHog analytics. No résumé content, other profile fields, prompts, answers, browser data, IP addresses, or raw errors are sent. `telemetry identity disable` stops identity sharing and rotates the analytics UUID so future usage is anonymous; `telemetry disable` stops all analytics. Previously collected events remain subject to the retention policy.
|
|
127
151
|
|
|
128
152
|
Anonymous community sharing is also enabled by default, separately from analytics. Confirmed applications share only a canonical public job URL, company, role, application channel, optional coarse discovery source, and derived provider URL—never candidate identity, answers, résumé, score, referral parameters, or submission timestamp. Repeatable discovery surfaces share their bounded catalog metadata through a maintainer-review queue. The registry stores no raw installation IDs; record-scoped contributor hashes are used only for deduplication and counts, never as identity or publication authority.
|
|
@@ -154,6 +178,18 @@ Updates are staged and validated before replacement. Private candidate state liv
|
|
|
154
178
|
|
|
155
179
|
</details>
|
|
156
180
|
|
|
181
|
+
## 📍 Where to find it
|
|
182
|
+
|
|
183
|
+
- [jobappagent.com](https://jobappagent.com) — product site (cloud launch Oct 1; open-source skill on GitHub/npm)
|
|
184
|
+
- [GitHub](https://github.com/vaibhavarora14/job-application-agent)
|
|
185
|
+
- [npm](https://www.npmjs.com/package/job-application-agent)
|
|
186
|
+
- [skills.sh](https://skills.sh/vaibhavarora14/job-application-agent)
|
|
187
|
+
- [Agent Skills discussion](https://github.com/agentskills/agentskills/discussions/537)
|
|
188
|
+
- [awesome-agent-skills (Community Skills)](https://github.com/VoltAgent/awesome-agent-skills)
|
|
189
|
+
- [SkillHub](https://www.skillhub.club/app/skills/b99cbef5-2445-47d0-94d0-e6d1fac81263) (may still be in review)
|
|
190
|
+
- [ClawHub](https://clawhub.ai/vaibhavarora14/skills/job-application-agent)
|
|
191
|
+
- [cursor.directory](https://cursor.directory/plugins/job-application-agent) (pending review)
|
|
192
|
+
|
|
157
193
|
## 🧰 Develop
|
|
158
194
|
|
|
159
195
|
```bash
|
|
@@ -35,11 +35,26 @@ async function isDirectory(filePath) {
|
|
|
35
35
|
try { return (await lstat(filePath)).isDirectory(); } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
async function
|
|
38
|
+
async function packagedCapabilities(source) {
|
|
39
|
+
try {
|
|
40
|
+
const value = JSON.parse(await readFile(path.join(source, 'capabilities.json'), 'utf8'));
|
|
41
|
+
return Array.isArray(value.capabilities) ? value.capabilities.filter((item) => typeof item === 'string') : [];
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === 'ENOENT') return [];
|
|
44
|
+
throw new Error('Invalid packaged skill: capabilities.json is malformed.');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function validatePackagedSkill(source, requiredCapabilities = []) {
|
|
39
49
|
const skillFile = path.join(source, 'SKILL.md');
|
|
40
50
|
const content = await readFile(skillFile, 'utf8').catch(() => '');
|
|
41
51
|
if (!content.trim()) throw new Error('Invalid packaged skill: SKILL.md is missing or empty.');
|
|
42
52
|
await stat(path.join(source, 'scripts', 'job-application.mjs')).catch(() => { throw new Error('Invalid packaged skill: application CLI is missing.'); });
|
|
53
|
+
const capabilities = await packagedCapabilities(source);
|
|
54
|
+
for (const required of requiredCapabilities) {
|
|
55
|
+
if (!capabilities.includes(required)) throw new Error(`Invalid packaged skill: required capability ${required} is missing.`);
|
|
56
|
+
}
|
|
57
|
+
return capabilities;
|
|
43
58
|
}
|
|
44
59
|
|
|
45
60
|
async function writeConfig(configPath, config) {
|
|
@@ -131,12 +146,13 @@ export async function installSkill({
|
|
|
131
146
|
const paths = pathsFor(home);
|
|
132
147
|
const source = path.join(packageRoot, SKILL_NAME);
|
|
133
148
|
await migrateLegacyCodexInstall({ homeDir, agentHome: home, legacyHome: legacyHome || resolveLegacyCodexHome(homeDir) });
|
|
134
|
-
await
|
|
149
|
+
const prior = await readInstallStatus({ homeDir, agentHome: home });
|
|
150
|
+
const capabilities = await validatePackagedSkill(source, prior.requiredCapabilities ?? []);
|
|
135
151
|
await mkdir(path.dirname(paths.target), { recursive: true });
|
|
136
152
|
await mkdir(paths.managerDir, { recursive: true });
|
|
137
153
|
const staging = path.join(paths.managerDir, `staging-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
138
154
|
await cp(source, staging, { recursive: true, force: true });
|
|
139
|
-
await validatePackagedSkill(staging);
|
|
155
|
+
await validatePackagedSkill(staging, prior.requiredCapabilities ?? []);
|
|
140
156
|
|
|
141
157
|
const hadTarget = await exists(paths.target);
|
|
142
158
|
if (hadTarget) {
|
|
@@ -151,13 +167,14 @@ export async function installSkill({
|
|
|
151
167
|
throw error;
|
|
152
168
|
}
|
|
153
169
|
|
|
154
|
-
const prior = await readInstallStatus({ homeDir, agentHome: home });
|
|
155
170
|
const config = {
|
|
156
171
|
installed: true,
|
|
157
172
|
installedVersion: packageVersion,
|
|
158
173
|
automaticUpdates: prior.installed ? prior.automaticUpdates !== false : true,
|
|
159
174
|
installedAt: prior.installedAt || new Date().toISOString(),
|
|
160
175
|
updatedAt: new Date().toISOString(),
|
|
176
|
+
capabilities,
|
|
177
|
+
...(prior.requiredCapabilities ? { requiredCapabilities: prior.requiredCapabilities } : {}),
|
|
161
178
|
};
|
|
162
179
|
await writeConfig(paths.configPath, config);
|
|
163
180
|
await syncVendorSkillCopies({ homeDir, sourceSkillDir: paths.target });
|
|
@@ -20,7 +20,7 @@ Use `scripts/job-application.mjs` for private state and deterministic checks. Re
|
|
|
20
20
|
1. Ask for a local PDF or read-only Google Docs resume URL. Import it without modifying the source.
|
|
21
21
|
2. Run `profile check`. If it reports missing or legacy fields, collect only facts that cannot be preserved or defaulted, then run `profile migrate --stdin`. Use `profile set --stdin` for a new profile.
|
|
22
22
|
3. Preserve identity fields during migration. Map legacy `salaryPreference` to `targetCompensation`. Add `compensationFloor` only when the candidate provides an amount, currency, and annual comparison basis.
|
|
23
|
-
4. Store the profile in OS-backed profile storage (macOS Keychain, Windows Credential Manager with a DPAPI-protected local file, or Linux Secret Service via `secret-tool`). Store the canonical resume and append-only ledgers in the owner-only state directory.
|
|
23
|
+
4. Store the profile in OS-backed profile storage (macOS Keychain, Windows Credential Manager with a DPAPI-protected local file, or Linux Secret Service via `secret-tool`). Store the canonical resume and append-only ledgers in the owner-only state directory. When private cloud state is configured, read and write the profile, résumé, and structured records through the v2 adapter instead. Owner-only local caches support browser uploads on macOS and Linux without Keychain access.
|
|
24
24
|
5. Use `review-each` for per-application approval. Use `routine-auto` only when the current request authorizes the destination or batch and every automatic-eligibility condition passes.
|
|
25
25
|
6. When the candidate explicitly grants continuing autonomy, read [references/AUTONOMY.md](references/AUTONOMY.md) and persist it with `autonomy grant --stdin`. Do not repeat skill-level upload or submission approval prompts while the active grant and profile both use `routine-auto`.
|
|
26
26
|
7. Obey browser and tool confirmation requirements regardless of the stored mode or autonomy grant.
|
|
@@ -54,19 +54,21 @@ Do not lower seniority, compensation, location, work mode, or evidence threshold
|
|
|
54
54
|
For batches, scheduled work, or resumable handoffs, read [references/RUNS.md](references/RUNS.md), create a round ID, and use the attention and friction queues.
|
|
55
55
|
Check `round status` after the initial discovery pass and before submitting. Preserve source attribution independently of the ATS. A round cannot complete without recorded coverage and attribution; if one discovery source supplies more than 60% of confirmed submissions, explain why using the reviewed alternatives and their fit or access results. Do not submit weaker matches to balance source percentages. Report searched sources, blockers, source mix, and any concentration explanation when handing off or completing a round.
|
|
56
56
|
|
|
57
|
-
1.
|
|
58
|
-
2.
|
|
59
|
-
3.
|
|
60
|
-
4.
|
|
61
|
-
5.
|
|
62
|
-
6.
|
|
63
|
-
7.
|
|
64
|
-
8.
|
|
65
|
-
9.
|
|
66
|
-
10.
|
|
67
|
-
11.
|
|
68
|
-
12.
|
|
69
|
-
13.
|
|
57
|
+
1. When private cloud state is configured, run `cloud status`, acquire the application-run lease with `cloud lease-acquire`, and renew it at least every five minutes. A client without the live lease may research and draft but must not submit.
|
|
58
|
+
2. Recheck employer, title, direct domain, posting status, eligibility, and `autoEligible` immediately before submission.
|
|
59
|
+
3. Run `ledger check --stdin` with the internal ledger ID, canonical URL, employer job ID, company, and role when available. Review both requisition duplicate status and same-company history.
|
|
60
|
+
4. Stop on a hard ledger-ID, canonical-URL, employer-job-ID, or requisition duplicate. Treat a same-company/same-role alias as a possible duplicate. Use `duplicateOverride: "NEW REQUISITION CONFIRMED"` only after verifying it is a distinct requisition.
|
|
61
|
+
5. For a genuinely different role at a previously applied company, follow `companyReapply`: proceed automatically only when it returns `eligible-after-cooldown` (15 full days since the latest company application and no recorded outcome). `cooldown-active` and `follow-up-present` require the candidate's explicit approval and `companyReapplyOverride: "CANDIDATE APPROVED EARLY REAPPLICATION"`.
|
|
62
|
+
6. Keep authentication in the existing browser session. Never inspect cookies, local storage, passwords, or session files.
|
|
63
|
+
7. Fill only explicit profile fields, candidate-provided answers, or facts verified in the canonical resume.
|
|
64
|
+
8. Follow [references/APPLICATION_GUIDANCE.md](references/APPLICATION_GUIDANCE.md) for narrative answers.
|
|
65
|
+
9. Upload only the canonical resume unless the candidate explicitly provides another attachment. Resolve its absolute path with `resume path`, then follow [references/BROWSER_UPLOADS.md](references/BROWSER_UPLOADS.md). Use the browser's privileged path-based upload capability first; treat a visible native file picker as a fallback.
|
|
66
|
+
10. Do not answer demographic questions. Stop for login/SSO/MFA, CAPTCHA, legal attestations, unclear authorization or compensation, sensitive identifiers, and judgment-only questions.
|
|
67
|
+
11. In cloud mode, create an application intent with `cloud intent-prepare --stdin` immediately before transmission. It rechecks the active lease and cloud duplicate history. If transmission occurs but confirmation is ambiguous, mark it with `cloud intent-sent --stdin`; never retry that application until the ATS or sent email is verified.
|
|
68
|
+
12. Verify every required field, answer, attachment, and disclosure. Submit when the current request or active autonomy grant authorizes it.
|
|
69
|
+
13. Record `submitted` only after visible success confirmation, using independent `discoverySource`, `discoverySourceId`, `applicationChannel`, and `roundId` values. In cloud mode include the returned `cloudIntentId` and active `cloudLeaseId` in `ledger add`; confirmation atomically records the application and round progress. `ledger add` automatically shares the sanitized public job metadata and durably retries on relay failure; do not run a separate manual contribution. Record no submission when confirmation is missing or ambiguous.
|
|
70
|
+
14. Record workflow telemetry with `telemetry record --stdin`. Let `ledger add` emit `application_submitted`; do not emit it twice. Pass job URLs and structured metrics only through documented transient fields.
|
|
71
|
+
15. Queue hard stops with `attention add --stdin` and continue elsewhere. Record reproducible general-purpose failures with `friction record --stdin`; improvement work must never delay application work.
|
|
70
72
|
|
|
71
73
|
## Outcomes and reviews
|
|
72
74
|
|
|
@@ -83,6 +85,12 @@ Check `round status` after the initial discovery pass and before submitting. Pre
|
|
|
83
85
|
## Commands
|
|
84
86
|
|
|
85
87
|
```text
|
|
88
|
+
node scripts/job-application.mjs cloud configure --stdin
|
|
89
|
+
node scripts/job-application.mjs cloud status
|
|
90
|
+
node scripts/job-application.mjs cloud reconcile [--dry-run]
|
|
91
|
+
node scripts/job-application.mjs cloud export [owner-only-path]
|
|
92
|
+
node scripts/job-application.mjs cloud lease-acquire|lease-renew|lease-release
|
|
93
|
+
node scripts/job-application.mjs cloud intent-prepare|intent-sent|intent-confirm --stdin
|
|
86
94
|
node scripts/job-application.mjs profile set --stdin
|
|
87
95
|
node scripts/job-application.mjs profile migrate --stdin
|
|
88
96
|
node scripts/job-application.mjs profile check
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Optional private cloud state
|
|
2
|
+
|
|
3
|
+
Private cloud state lets multiple trusted agent hosts use one authoritative candidate dataset. It is separate from anonymous analytics and the public community registry.
|
|
4
|
+
|
|
5
|
+
Configure each host with a different revocable client token:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
echo '{"version":2,"url":"https://private-worker.example","clientId":"mac-codex","clientName":"Mac Codex","token":"..."}' | node scripts/job-application.mjs cloud configure --stdin
|
|
9
|
+
node scripts/job-application.mjs cloud status
|
|
10
|
+
node scripts/job-application.mjs cloud reconcile --dry-run
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The owner-only configuration file stores the token with mode `0600`. The server stores only its SHA-256 hash. D1 stores revisioned documents, append-only records, application intents, and the single-writer lease. R2 stores the canonical résumé, migration snapshots, and 30-day backups when enabled; an existing private Workers KV namespace is supported as a compatible blob fallback.
|
|
14
|
+
|
|
15
|
+
Browser sessions, Gmail credentials, passwords, verification codes, CAPTCHA responses, demographic responses, legal answers, and device-specific telemetry credentials never enter private cloud state.
|
|
16
|
+
|
|
17
|
+
Normal profile, résumé, ledger, outcome, round, attention, review, and friction commands automatically reconcile through the configured backend. `cloud export` creates an owner-only JSON archive. During a cloud outage, continue cached research and drafts but do not transmit a new application.
|
|
18
|
+
|
|
19
|
+
For a shared Linux host where Codex and another agent need separate credentials, follow [`VPS_CLIENTS.md`](VPS_CLIENTS.md). Keep a single scheduler and rely on the D1 lease—not local process assumptions—to enforce the one-writer rule.
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Resumable application runs
|
|
2
2
|
|
|
3
|
+
## Shared cloud coordination
|
|
4
|
+
|
|
5
|
+
If `cloud status` reports a Cloudflare D1 backend (`cloudflare-d1-r2` or the private `cloudflare-d1-kv` blob fallback), acquire one 15-minute application-run lease before any transmission and renew it every five minutes. Other clients may still read shared state and record independent outcomes. A cloud outage pauses new submissions, while cached research and drafting may continue.
|
|
6
|
+
|
|
7
|
+
Immediately before sending an application, create an intent containing its application ID, canonical URL, round ID, and active lease ID. After visible confirmation, pass the intent and lease IDs to `ledger add`; the Worker records the submission and round progress together. If a send may have happened but confirmation is unavailable, mark the intent `sent-unverified`. Lease expiry never makes an unverified intent safe to retry.
|
|
8
|
+
|
|
3
9
|
## Round lifecycle
|
|
4
10
|
|
|
5
11
|
Start each batch with an explicit ID:
|
|
@@ -125,13 +125,17 @@ Add only after visible success confirmation.
|
|
|
125
125
|
"fieldsFilled": 14,
|
|
126
126
|
"shortAnswerCount": 2,
|
|
127
127
|
"resumeUploaded": true
|
|
128
|
-
}
|
|
128
|
+
},
|
|
129
|
+
"cloudIntentId": "intent UUID returned before transmission",
|
|
130
|
+
"cloudLeaseId": "active application-run lease UUID"
|
|
129
131
|
}
|
|
130
132
|
```
|
|
131
133
|
|
|
132
134
|
Use `duplicateOverride` only for a verified distinct requisition after a possible-duplicate warning. Use `companyReapplyOverride` only when the candidate explicitly approves a different-role reapplication during the cooldown or after a recorded outcome. Both override phrases and `telemetry` are transient. An accepted company reapplication override stores only `reapplicationApproval: "candidate-explicit"` in the private ledger. Use approval `APPROVE SUBMIT` for per-application approval or `STANDING AUTHORIZATION` when the current request authorizes routine batch submission.
|
|
133
135
|
|
|
134
|
-
`
|
|
136
|
+
`cloudIntentId` and `cloudLeaseId` are transient coordination fields. They are required for the strongest atomic cloud path and are never written into the application payload itself.
|
|
137
|
+
|
|
138
|
+
`discoverySource`, `discoverySourceId`, `applicationChannel`, and `roundId` are optional for backward compatibility and should be supplied for new resumable rounds. `discoverySourceId` accepts stable packaged and `community-…` source IDs; per-application attribution stays in private state and is not included in telemetry. Separate source-coverage events may include packaged source IDs, while community IDs are collapsed to `community`. After a visibly confirmed submission, `ledger add` automatically contributes only the canonical public URL, company, role, application channel, optional coarse discovery source, and derived provider URL to the pending community job registry. Existing installations receive a one-command disclosure grace period before historical backfill; `sources sharing disable` opts out. Private answers, score, submission time, IDs, and round remain in the owner-only local or configured cloud state, and no job appears publicly before maintainer review. `ledger check` returns hard duplicate status, bounded same-company history, and the same `companyReapply` decision enforced by `ledger add` while holding the application lock.
|
|
135
139
|
|
|
136
140
|
## Autonomy grant input
|
|
137
141
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# VPS client instructions
|
|
2
|
+
|
|
3
|
+
This host is a reader by default. It must acquire the shared Cloudflare application-run lease before transmitting any application.
|
|
4
|
+
|
|
5
|
+
## VPS Codex
|
|
6
|
+
|
|
7
|
+
Use the installed skill and its default Linux cloud configuration:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
node "$HOME/.agents/skills/job-application-agent/scripts/job-application.mjs" cloud status
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
All profile, resume, ledger, outcome, round, attention, and review commands must use that same CLI. Do not create or treat an independent local ledger as authoritative.
|
|
14
|
+
|
|
15
|
+
## VPS Antigravity
|
|
16
|
+
|
|
17
|
+
Use the dedicated wrapper so Antigravity presents its separately revocable client credential:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
"$HOME/.local/bin/job-application-agent-antigravity" cloud status
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Never copy the Codex credential into Antigravity configuration or print either token. Browser sessions, Gmail credentials, passwords, and verification codes stay on the host and are not uploaded.
|
|
24
|
+
|
|
25
|
+
## Writer protocol
|
|
26
|
+
|
|
27
|
+
1. Require a healthy `cloud status` result.
|
|
28
|
+
2. Acquire the application-run lease before application work and renew it every five minutes.
|
|
29
|
+
3. Create an application intent before each transmission.
|
|
30
|
+
4. Confirm the intent only after visible submission evidence; mark an ambiguous send `sent-unverified` and do not retry it.
|
|
31
|
+
5. Release the lease when the run finishes or stops.
|
|
32
|
+
|
|
33
|
+
If cloud state is unavailable, continue cached research and drafting only. Do not submit.
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { appendFile, chmod, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir, platform } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const CLOUD_STREAM_FILES = Object.freeze({
|
|
7
|
+
applications: 'applications.ndjson',
|
|
8
|
+
outcomes: 'outcomes.ndjson',
|
|
9
|
+
rounds: 'rounds.ndjson',
|
|
10
|
+
discovery: 'discovery.ndjson',
|
|
11
|
+
attention: 'attention.ndjson',
|
|
12
|
+
reviews: 'reviews.ndjson',
|
|
13
|
+
friction: 'friction.ndjson',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const CLOUD_DOCUMENT_FILES = Object.freeze({
|
|
17
|
+
profile: 'cloud-profile-cache.json',
|
|
18
|
+
autonomy: 'autonomy.json',
|
|
19
|
+
'review-policy': 'review-policy.json',
|
|
20
|
+
'postal-address': 'postal-address.json',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const FORBIDDEN_KEYS = /^(password|passwd|cookie|cookies|mfa|mfaCode|totp|ssn|passport|aadhaar|governmentId|governmentID|nationalId|sessionCookie|browserCookies|credential|credentials)$/i;
|
|
24
|
+
|
|
25
|
+
export function defaultCloudConfigPath(home = homedir()) {
|
|
26
|
+
if (platform() === 'darwin') return join(home, 'Library', 'Application Support', 'job-application-agent-cloud', 'config.json');
|
|
27
|
+
if (platform() === 'win32') return join(process.env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'job-application-agent-cloud', 'config.json');
|
|
28
|
+
return join(process.env.XDG_CONFIG_HOME || join(home, '.config'), 'job-application-agent', 'cloud.json');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hash(bytes) {
|
|
32
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function scanForbidden(value, depth = 0) {
|
|
36
|
+
if (depth > 8 || value == null) return null;
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
for (const item of value) {
|
|
39
|
+
const found = scanForbidden(item, depth + 1);
|
|
40
|
+
if (found) return found;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (typeof value !== 'object') return null;
|
|
45
|
+
for (const [key, item] of Object.entries(value)) {
|
|
46
|
+
if (FORBIDDEN_KEYS.test(key)) return key;
|
|
47
|
+
const found = scanForbidden(item, depth + 1);
|
|
48
|
+
if (found) return found;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function ensurePrivateDirectory(path) {
|
|
54
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
55
|
+
await chmod(path, 0o700);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function privateWrite(path, contents) {
|
|
59
|
+
await ensurePrivateDirectory(dirname(path));
|
|
60
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
61
|
+
await writeFile(temporary, contents, { mode: 0o600 });
|
|
62
|
+
await rename(temporary, path);
|
|
63
|
+
await chmod(path, 0o600);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function fileExists(path) {
|
|
67
|
+
try { await stat(path); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function saveCloudConfig(input, { configPath = defaultCloudConfigPath() } = {}) {
|
|
71
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Cloud configuration must be a JSON object.');
|
|
72
|
+
const url = String(input.url ?? '').replace(/\/+$/, '');
|
|
73
|
+
const token = String(input.token ?? '');
|
|
74
|
+
if (!/^https:\/\//i.test(url)) throw new Error('Cloud URL must use HTTPS.');
|
|
75
|
+
if (token.length < 32) throw new Error('Cloud token must be at least 32 characters.');
|
|
76
|
+
const config = {
|
|
77
|
+
version: 2,
|
|
78
|
+
url,
|
|
79
|
+
token,
|
|
80
|
+
...(input.clientId ? { clientId: String(input.clientId) } : {}),
|
|
81
|
+
...(input.clientName ? { clientName: String(input.clientName) } : {}),
|
|
82
|
+
configuredAt: new Date().toISOString(),
|
|
83
|
+
};
|
|
84
|
+
await privateWrite(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
85
|
+
return { ...config, token: tokenSuffix(token) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function enableCloudUpdateGuard({ home = homedir(), agentHome = process.env.JOB_APPLICATION_AGENT_HOME } = {}) {
|
|
89
|
+
const root = agentHome || join(home, '.agents');
|
|
90
|
+
const path = join(root, 'job-application-agent', 'install.json');
|
|
91
|
+
let config;
|
|
92
|
+
try { config = JSON.parse(await readFile(path, 'utf8')); }
|
|
93
|
+
catch (error) { if (error.code === 'ENOENT') return { guarded: false, reason: 'managed-install-not-found' }; throw error; }
|
|
94
|
+
const required = new Set(config.requiredCapabilities ?? []);
|
|
95
|
+
required.add('cloud-state-v2');
|
|
96
|
+
await privateWrite(path, `${JSON.stringify({ ...config, requiredCapabilities: [...required].sort() }, null, 2)}\n`);
|
|
97
|
+
return { guarded: true, capability: 'cloud-state-v2' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function tokenSuffix(token) {
|
|
101
|
+
return token.length >= 4 ? `****${token.slice(-4)}` : '****';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function loadCloudConfig({ configPath = defaultCloudConfigPath(), optional = false } = {}) {
|
|
105
|
+
try {
|
|
106
|
+
const value = JSON.parse(await readFile(configPath, 'utf8'));
|
|
107
|
+
const url = String(value.url ?? '').replace(/\/+$/, '');
|
|
108
|
+
const token = String(value.token ?? '');
|
|
109
|
+
if (!/^https:\/\//i.test(url) || token.length < 24) throw new Error('Cloud configuration is malformed.');
|
|
110
|
+
return { ...value, url, token };
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (optional && error.code === 'ENOENT') return null;
|
|
113
|
+
if (error.code === 'ENOENT') throw new Error(`Cloud configuration is missing at ${configPath}. Run cloud configure --stdin.`);
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function responseError(response) {
|
|
119
|
+
try { return (await response.json()).error ?? `HTTP ${response.status}`; } catch { return `HTTP ${response.status}`; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class CloudStateClient {
|
|
123
|
+
constructor({ stateDir, configPath = defaultCloudConfigPath(), fetchImpl = fetch } = {}) {
|
|
124
|
+
if (!stateDir) throw new Error('stateDir is required.');
|
|
125
|
+
this.stateDir = stateDir;
|
|
126
|
+
this.configPath = configPath;
|
|
127
|
+
this.fetchImpl = fetchImpl;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async config(optional = false) {
|
|
131
|
+
return loadCloudConfig({ configPath: this.configPath, optional });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async configured() {
|
|
135
|
+
return (await this.config(true))?.version === 2;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async request(path, init = {}) {
|
|
139
|
+
const config = await this.config();
|
|
140
|
+
if (config.version !== 2) throw new Error('Cloud configuration uses the retired whole-file protocol. Run cloud configure --stdin with a v2 client credential.');
|
|
141
|
+
let response;
|
|
142
|
+
try {
|
|
143
|
+
response = await this.fetchImpl(`${config.url}${path}`, {
|
|
144
|
+
...init,
|
|
145
|
+
headers: { authorization: `Bearer ${config.token}`, ...(init.headers ?? {}) },
|
|
146
|
+
});
|
|
147
|
+
} catch (error) {
|
|
148
|
+
throw new Error(`Cloud state unavailable: ${error.message}`);
|
|
149
|
+
}
|
|
150
|
+
if (!response.ok) throw new Error(`Cloud state request failed (${response.status}): ${await responseError(response)}`);
|
|
151
|
+
return response;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async status() {
|
|
155
|
+
const config = await this.config(true);
|
|
156
|
+
if (!config) return { configured: false, backend: 'local', pendingLocalWrites: 0 };
|
|
157
|
+
const pending = await this.pendingWrites();
|
|
158
|
+
const remote = await (await this.request('/v2/status')).json();
|
|
159
|
+
return { configured: true, url: config.url, configuredClient: { id: config.clientId ?? null, name: config.clientName ?? null, token: tokenSuffix(config.token) }, pendingLocalWrites: pending.length, ...remote };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async getDocument(name) {
|
|
163
|
+
const response = await this.request(`/v2/documents/${encodeURIComponent(name)}`);
|
|
164
|
+
return response.json();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async putDocument(name, value, revision) {
|
|
168
|
+
const forbidden = scanForbidden(value);
|
|
169
|
+
if (forbidden) throw new Error(`Cloud document contains forbidden field: ${forbidden}`);
|
|
170
|
+
const response = await this.request(`/v2/documents/${encodeURIComponent(name)}`, {
|
|
171
|
+
method: 'PUT',
|
|
172
|
+
headers: { 'content-type': 'application/json', 'if-match': String(revision) },
|
|
173
|
+
body: JSON.stringify({ value }),
|
|
174
|
+
});
|
|
175
|
+
return response.json();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async putDocumentCurrent(name, value) {
|
|
179
|
+
let revision = 0;
|
|
180
|
+
try { revision = (await this.getDocument(name)).revision; }
|
|
181
|
+
catch (error) { if (!/\(404\)/.test(error.message)) throw error; }
|
|
182
|
+
return this.putDocument(name, value, revision);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async listStream(stream) {
|
|
186
|
+
const records = [];
|
|
187
|
+
let cursor = 0;
|
|
188
|
+
do {
|
|
189
|
+
const page = await (await this.request(`/v2/streams/${encodeURIComponent(stream)}?after=${cursor}&limit=1000`)).json();
|
|
190
|
+
records.push(...page.records);
|
|
191
|
+
if (page.nextCursor === cursor || page.records.length === 0) break;
|
|
192
|
+
cursor = page.nextCursor;
|
|
193
|
+
} while (true);
|
|
194
|
+
return records;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async appendRecord(stream, value, { recordKey, idempotencyKey, occurredAt, provenance = 'live', queueOnFailure = false } = {}) {
|
|
198
|
+
const forbidden = scanForbidden(value);
|
|
199
|
+
if (forbidden) throw new Error(`Cloud record contains forbidden field: ${forbidden}`);
|
|
200
|
+
const payload = {
|
|
201
|
+
recordKey: String(recordKey ?? value?.id ?? value?.roundId ?? randomUUID()),
|
|
202
|
+
idempotencyKey: String(idempotencyKey ?? `${stream}:${hash(JSON.stringify(value))}`),
|
|
203
|
+
occurredAt: occurredAt ?? value?.occurredAt ?? value?.submittedAt ?? new Date().toISOString(),
|
|
204
|
+
provenance,
|
|
205
|
+
value,
|
|
206
|
+
};
|
|
207
|
+
try {
|
|
208
|
+
return await (await this.request(`/v2/streams/${encodeURIComponent(stream)}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })).json();
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (!queueOnFailure) throw error;
|
|
211
|
+
await this.queueWrite({ type: 'append-record', stream, payload, queuedAt: new Date().toISOString() });
|
|
212
|
+
return { queued: true, error: 'cloud_unavailable' };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async appendRecordBatch(stream, records) {
|
|
217
|
+
if (!Array.isArray(records) || records.length < 1 || records.length > 100) throw new Error('Cloud record batch must contain 1 to 100 records.');
|
|
218
|
+
const response = await this.request(`/v2/streams/${encodeURIComponent(stream)}/batch`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ records }) });
|
|
219
|
+
return response.json();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async queueWrite(event) {
|
|
223
|
+
await ensurePrivateDirectory(this.stateDir);
|
|
224
|
+
const path = join(this.stateDir, 'cloud-pending.ndjson');
|
|
225
|
+
await appendFile(path, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
226
|
+
await chmod(path, 0o600);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async pendingWrites() {
|
|
230
|
+
try { return (await readFile(join(this.stateDir, 'cloud-pending.ndjson'), 'utf8')).split('\n').filter(Boolean).map(JSON.parse); }
|
|
231
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async putFile(name, bytes, revision) {
|
|
235
|
+
const body = Buffer.from(bytes);
|
|
236
|
+
const response = await this.request(`/v2/files/${encodeURIComponent(name)}`, { method: 'PUT', headers: { 'content-type': name.endsWith('.pdf') ? 'application/pdf' : 'application/json', 'if-match': String(revision), 'x-content-sha256': hash(body) }, body });
|
|
237
|
+
return response.json();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async putFileCurrent(name, bytes) {
|
|
241
|
+
let revision = 0;
|
|
242
|
+
try {
|
|
243
|
+
const status = await this.status();
|
|
244
|
+
revision = status.files?.find((file) => file.name === name)?.revision ?? 0;
|
|
245
|
+
} catch (error) { if (!/\(404\)/.test(error.message)) throw error; }
|
|
246
|
+
return this.putFile(name, bytes, revision);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async fetchResume() {
|
|
250
|
+
const response = await this.request('/v2/files/resume.pdf');
|
|
251
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
252
|
+
const expected = response.headers.get('x-sha256');
|
|
253
|
+
const actual = hash(bytes);
|
|
254
|
+
if (!expected || expected !== actual) throw new Error('Cloud resume checksum verification failed.');
|
|
255
|
+
if (!bytes.subarray(0, 4).equals(Buffer.from('%PDF'))) throw new Error('Cloud resume is not a PDF.');
|
|
256
|
+
const path = join(this.stateDir, 'resume.pdf');
|
|
257
|
+
await privateWrite(path, bytes);
|
|
258
|
+
return { path, sha256: actual, bytes: bytes.length };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async refreshProfileCache() {
|
|
262
|
+
const document = await this.getDocument('profile');
|
|
263
|
+
await privateWrite(join(this.stateDir, 'cloud-profile-cache.json'), `${JSON.stringify(document.value)}\n`);
|
|
264
|
+
return document.value;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async refreshDocumentCaches() {
|
|
268
|
+
const refreshed = {};
|
|
269
|
+
for (const [name, filename] of Object.entries(CLOUD_DOCUMENT_FILES)) {
|
|
270
|
+
let document;
|
|
271
|
+
try { document = await this.getDocument(name); }
|
|
272
|
+
catch (error) { if (/\(404\)/.test(error.message)) continue; throw error; }
|
|
273
|
+
await privateWrite(join(this.stateDir, filename), `${JSON.stringify(document.value)}\n`);
|
|
274
|
+
refreshed[name] = { revision: document.revision, updatedAt: document.updatedAt ?? null };
|
|
275
|
+
}
|
|
276
|
+
return refreshed;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async acquireLease() {
|
|
280
|
+
return (await this.request('/v2/leases/application-run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'acquire' }) })).json();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async renewLease(leaseId) {
|
|
284
|
+
return (await this.request('/v2/leases/application-run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'renew', leaseId }) })).json();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async releaseLease(leaseId) {
|
|
288
|
+
return (await this.request('/v2/leases/application-run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'release', leaseId }) })).json();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async createIntent(value) {
|
|
292
|
+
return (await this.request('/v2/intents', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) })).json();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async markIntentSentUnverified(intentId, leaseId) {
|
|
296
|
+
return (await this.request(`/v2/intents/${encodeURIComponent(intentId)}/sent-unverified`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ leaseId }) })).json();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async confirmIntent(intentId, application, leaseId, idempotencyKey) {
|
|
300
|
+
return (await this.request(`/v2/intents/${encodeURIComponent(intentId)}/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ application, leaseId, idempotencyKey }) })).json();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async reconcile({ dryRun = true, provenance = 'local-reconcile' } = {}) {
|
|
304
|
+
const report = { dryRun, streams: {}, imported: 0, downloaded: 0 };
|
|
305
|
+
for (const [stream, filename] of Object.entries(CLOUD_STREAM_FILES)) {
|
|
306
|
+
const local = await readNdjson(join(this.stateDir, filename));
|
|
307
|
+
const cloudRecords = await this.listStream(stream);
|
|
308
|
+
const cloud = cloudRecords.map((record) => record.value);
|
|
309
|
+
const localCounts = multiset(local);
|
|
310
|
+
const cloudCounts = multiset(cloud);
|
|
311
|
+
const localOnly = multisetDifference(local, cloudCounts);
|
|
312
|
+
const cloudOnly = multisetDifference(cloud, localCounts);
|
|
313
|
+
report.streams[stream] = { localRows: local.length, cloudRows: cloud.length, localOnly: localOnly.length, cloudOnly: cloudOnly.length, unionRows: local.length + cloudOnly.length };
|
|
314
|
+
if (!dryRun) {
|
|
315
|
+
const prepared = localOnly.map(({ value, index }) => ({
|
|
316
|
+
recordKey: String(value?.id ?? value?.roundId ?? value?.applicationId ?? `${stream}-${index}`),
|
|
317
|
+
idempotencyKey: `reconcile:${hash(JSON.stringify(value))}:${index}`,
|
|
318
|
+
occurredAt: value?.occurredAt ?? value?.submittedAt ?? new Date().toISOString(),
|
|
319
|
+
provenance,
|
|
320
|
+
value,
|
|
321
|
+
}));
|
|
322
|
+
for (let offset = 0; offset < prepared.length; offset += 100) {
|
|
323
|
+
const result = await this.appendRecordBatch(stream, prepared.slice(offset, offset + 100));
|
|
324
|
+
report.imported += result.inserted;
|
|
325
|
+
}
|
|
326
|
+
if (cloudOnly.length) {
|
|
327
|
+
await ensurePrivateDirectory(this.stateDir);
|
|
328
|
+
const path = join(this.stateDir, filename);
|
|
329
|
+
for (const { value } of cloudOnly) await appendFile(path, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
330
|
+
await chmod(path, 0o600);
|
|
331
|
+
report.downloaded += cloudOnly.length;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return report;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async exportTo(path) {
|
|
339
|
+
const status = await this.status();
|
|
340
|
+
const documents = {};
|
|
341
|
+
for (const item of status.documents ?? []) documents[item.name] = await this.getDocument(item.name);
|
|
342
|
+
const streams = {};
|
|
343
|
+
for (const stream of Object.keys(CLOUD_STREAM_FILES)) streams[stream] = await this.listStream(stream);
|
|
344
|
+
const output = path ?? join(this.stateDir, `cloud-export-${new Date().toISOString().replaceAll(':', '-')}.json`);
|
|
345
|
+
await privateWrite(output, `${JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), backend: status.backend, documents, streams, files: status.files ?? [] })}\n`);
|
|
346
|
+
return { path: output, documents: Object.keys(documents).length, records: Object.values(streams).reduce((sum, rows) => sum + rows.length, 0) };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function readNdjson(path) {
|
|
351
|
+
try { return (await readFile(path, 'utf8')).split('\n').filter(Boolean).map((line) => JSON.parse(line)); }
|
|
352
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function key(value) {
|
|
356
|
+
return JSON.stringify(value);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function multiset(values) {
|
|
360
|
+
const counts = new Map();
|
|
361
|
+
for (const value of values) counts.set(key(value), (counts.get(key(value)) ?? 0) + 1);
|
|
362
|
+
return counts;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function multisetDifference(values, otherCounts) {
|
|
366
|
+
const remaining = new Map(otherCounts);
|
|
367
|
+
const result = [];
|
|
368
|
+
values.forEach((value, index) => {
|
|
369
|
+
const itemKey = key(value);
|
|
370
|
+
const count = remaining.get(itemKey) ?? 0;
|
|
371
|
+
if (count > 0) remaining.set(itemKey, count - 1);
|
|
372
|
+
else result.push({ value, index });
|
|
373
|
+
});
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export async function readCachedCloudProfile(stateDir) {
|
|
378
|
+
try { return JSON.parse(await readFile(join(stateDir, 'cloud-profile-cache.json'), 'utf8')); }
|
|
379
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function cloudDocumentFile(name) {
|
|
383
|
+
return CLOUD_DOCUMENT_FILES[name] ?? null;
|
|
384
|
+
}
|
|
@@ -12,6 +12,7 @@ import { SourceCommunityClient } from './source-community-client.mjs';
|
|
|
12
12
|
import { normalizeCommunityJob, normalizeCommunitySource } from './source-community-schema.mjs';
|
|
13
13
|
import { TelemetryClient } from './telemetry-client.mjs';
|
|
14
14
|
import { jobIdentity } from './telemetry-schema.mjs';
|
|
15
|
+
import { CloudStateClient, defaultCloudConfigPath, enableCloudUpdateGuard, saveCloudConfig } from './cloud-state-client.mjs';
|
|
15
16
|
|
|
16
17
|
const SOURCES = new Set(['linkedin', 'greenhouse', 'lever', 'ashby', 'workable', 'comeet', 'workday', 'rippling', 'smartrecruiters', 'google-form', 'company', 'email', 'other']);
|
|
17
18
|
const DISCOVERY_SOURCES = new Set(['direct-company', 'linkedin', 'x', 'yc', 'hacker-news', 'job-board', 'email', 'user-supplied', 'web-search', 'other']);
|
|
@@ -67,6 +68,17 @@ function stateDir() {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
const secretStore = createSecretStore({ stateDir });
|
|
71
|
+
const cloudState = new CloudStateClient({ stateDir: stateDir(), configPath: process.env.JOB_APPLICATION_AGENT_CLOUD_CONFIG ?? defaultCloudConfigPath() });
|
|
72
|
+
|
|
73
|
+
function cachedCloudProfileRaw() {
|
|
74
|
+
try {
|
|
75
|
+
const config = JSON.parse(readFileSync(process.env.JOB_APPLICATION_AGENT_CLOUD_CONFIG ?? defaultCloudConfigPath(), 'utf8'));
|
|
76
|
+
if (config.version !== 2) return null;
|
|
77
|
+
return object(JSON.parse(readFileSync(join(stateDir(), 'cloud-profile-cache.json'), 'utf8')), 'profile');
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
70
82
|
|
|
71
83
|
export function durationBucket(milliseconds) {
|
|
72
84
|
if (milliseconds < 1_000) return 'under-1s';
|
|
@@ -604,6 +616,8 @@ export function buildReview(entries, outcomeEntries = [], acknowledgements = [],
|
|
|
604
616
|
}
|
|
605
617
|
|
|
606
618
|
function storedProfileRaw() {
|
|
619
|
+
const cloudProfile = cachedCloudProfileRaw();
|
|
620
|
+
if (cloudProfile) return cloudProfile;
|
|
607
621
|
try { return object(JSON.parse(secretStore.readProfile()), 'profile'); } catch (error) {
|
|
608
622
|
if (/missing or unreadable|could not read|could not store|Windows profile storage|not supported on this platform|secret-tool is not installed/i.test(error.message)) throw error;
|
|
609
623
|
throw new Error('The stored profile is missing or unreadable. Run profile set again.');
|
|
@@ -863,6 +877,7 @@ async function autonomyGrant(input) {
|
|
|
863
877
|
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown autonomy grant property: ${key}.`);
|
|
864
878
|
if (value.mode !== 'routine-auto') throw new Error('autonomy grant mode must be routine-auto.');
|
|
865
879
|
const grant = { version: 1, enabled: true, mode: 'routine-auto', scopes: [...AUTONOMY_SCOPES], grantedAt: new Date().toISOString() };
|
|
880
|
+
if (await cloudState.configured()) await cloudState.putDocumentCurrent('autonomy', grant);
|
|
866
881
|
const file = join(await ensureStateDir(), 'autonomy.json');
|
|
867
882
|
await writePrivateJson(file, grant);
|
|
868
883
|
return autonomyView(grant);
|
|
@@ -872,14 +887,21 @@ async function autonomyRevoke() {
|
|
|
872
887
|
const file = join(await ensureStateDir(), 'autonomy.json');
|
|
873
888
|
const current = await readPrivateJson(file, { version: 1, enabled: false });
|
|
874
889
|
const revoked = { version: 1, enabled: false, revokedAt: new Date().toISOString(), ...(current.grantedAt ? { grantedAt: current.grantedAt } : {}) };
|
|
890
|
+
if (await cloudState.configured()) await cloudState.putDocumentCurrent('autonomy', revoked);
|
|
875
891
|
await writePrivateJson(file, revoked);
|
|
876
892
|
return autonomyView(revoked);
|
|
877
893
|
}
|
|
878
894
|
|
|
879
895
|
async function storeProfile(profileInput) {
|
|
880
896
|
const profile = validateProfile(profileInput);
|
|
881
|
-
if (
|
|
882
|
-
|
|
897
|
+
if (await cloudState.configured()) {
|
|
898
|
+
const dir = await ensureStateDir();
|
|
899
|
+
await cloudState.putDocumentCurrent('profile', profile);
|
|
900
|
+
await writePrivateJson(join(dir, 'cloud-profile-cache.json'), profile);
|
|
901
|
+
} else {
|
|
902
|
+
if (process.platform === 'win32') await ensureStateDir();
|
|
903
|
+
secretStore.writeProfile(JSON.stringify(profile));
|
|
904
|
+
}
|
|
883
905
|
return profile;
|
|
884
906
|
}
|
|
885
907
|
|
|
@@ -926,11 +948,23 @@ async function importResume(source) {
|
|
|
926
948
|
await chmod(target, 0o600);
|
|
927
949
|
const metadata = { source: sourceLabel, importedAt: new Date().toISOString(), sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length };
|
|
928
950
|
await writeFile(join(dir, 'resume.json'), `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
|
|
951
|
+
if (await cloudState.configured()) {
|
|
952
|
+
await cloudState.putFileCurrent('resume.pdf', bytes);
|
|
953
|
+
await cloudState.putFileCurrent('resume.json', Buffer.from(JSON.stringify(metadata)));
|
|
954
|
+
}
|
|
929
955
|
return { path: target, sha256: metadata.sha256, bytes: metadata.bytes };
|
|
930
956
|
}
|
|
931
957
|
|
|
932
958
|
async function canonicalResumePath() {
|
|
933
959
|
const target = join(await ensureStateDir(), 'resume.pdf');
|
|
960
|
+
if (await cloudState.configured()) {
|
|
961
|
+
try { await cloudState.fetchResume(); }
|
|
962
|
+
catch (error) {
|
|
963
|
+
// Cached research and browser preparation remain usable during an
|
|
964
|
+
// outage. Integrity, authentication, and checksum failures still stop.
|
|
965
|
+
if (!/^Cloud state unavailable:/.test(error.message)) throw error;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
934
968
|
try {
|
|
935
969
|
const details = await stat(target);
|
|
936
970
|
if (!details.isFile()) throw new Error('Canonical resume path is not a file. Import the resume again.');
|
|
@@ -1009,7 +1043,7 @@ async function ledgerCheck(candidate) {
|
|
|
1009
1043
|
return duplicateResult(entries, candidate, outcomes);
|
|
1010
1044
|
}
|
|
1011
1045
|
|
|
1012
|
-
async function ledgerAdd(entryInput, duplicateOverride, companyReapplyOverride) {
|
|
1046
|
+
async function ledgerAdd(entryInput, duplicateOverride, companyReapplyOverride, cloudIntent = null) {
|
|
1013
1047
|
const entry = validateLedgerEntry(entryInput);
|
|
1014
1048
|
return withStateLock('applications', async (dir) => {
|
|
1015
1049
|
const file = join(dir, 'applications.ndjson');
|
|
@@ -1039,15 +1073,30 @@ async function ledgerAdd(entryInput, duplicateOverride, companyReapplyOverride)
|
|
|
1039
1073
|
await appendFile(roundsFile, `${JSON.stringify({ type: 'submission-confirmed', roundId: entry.roundId, applicationId: entry.id, occurredAt: entry.submittedAt })}\n`, { mode: 0o600 });
|
|
1040
1074
|
await chmod(roundsFile, 0o600);
|
|
1041
1075
|
}
|
|
1042
|
-
|
|
1076
|
+
if (await cloudState.configured()) {
|
|
1077
|
+
if (cloudIntent?.intentId && cloudIntent?.leaseId) {
|
|
1078
|
+
await cloudState.confirmIntent(cloudIntent.intentId, storedEntry, cloudIntent.leaseId, `application:${storedEntry.id}:${storedEntry.submittedAt}`);
|
|
1079
|
+
} else {
|
|
1080
|
+
await cloudState.appendRecord('applications', storedEntry, { recordKey: storedEntry.id, idempotencyKey: `application:${storedEntry.id}:${storedEntry.submittedAt}`, occurredAt: storedEntry.submittedAt, queueOnFailure: true });
|
|
1081
|
+
if (entry.roundId) await cloudState.appendRecord('rounds', { type: 'submission-confirmed', roundId: entry.roundId, applicationId: entry.id, occurredAt: entry.submittedAt }, { recordKey: entry.roundId, idempotencyKey: `round-confirmation:${entry.roundId}:${entry.id}`, occurredAt: entry.submittedAt, queueOnFailure: true });
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return { recorded: storedEntry.id, review: buildReview([...entries, storedEntry]), ...(cloudIntent ? { cloudIntent: cloudIntent.intentId } : {}) };
|
|
1043
1085
|
});
|
|
1044
1086
|
}
|
|
1045
1087
|
|
|
1088
|
+
async function appendCloudEvent(name, event) {
|
|
1089
|
+
if (!await cloudState.configured()) return;
|
|
1090
|
+
const key = event.id ?? event.roundId ?? event.applicationId ?? randomUUID();
|
|
1091
|
+
await cloudState.appendRecord(name, event, { recordKey: key, idempotencyKey: `${name}:${createHash('sha256').update(JSON.stringify(event)).digest('hex')}`, occurredAt: event.occurredAt, queueOnFailure: true });
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1046
1094
|
async function appendPrivateEvent(name, event) {
|
|
1047
1095
|
return withStateLock(name, async (dir) => {
|
|
1048
1096
|
const file = join(dir, `${name}.ndjson`);
|
|
1049
1097
|
await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
1050
1098
|
await chmod(file, 0o600);
|
|
1099
|
+
if (['rounds', 'discovery', 'attention', 'friction'].includes(name)) await appendCloudEvent(name, event);
|
|
1051
1100
|
return event;
|
|
1052
1101
|
});
|
|
1053
1102
|
}
|
|
@@ -1131,7 +1180,7 @@ async function roundSource(input) {
|
|
|
1131
1180
|
const evidence = string(value.evidence, 'coverage.evidence', 2000);
|
|
1132
1181
|
const applicationIds = value.applicationIds == null ? [] : [...new Set(stringArray(value.applicationIds, 'coverage.applicationIds'))];
|
|
1133
1182
|
if (applicationIds.length > 1000 || (status === 'blocked' && applicationIds.length)) throw new Error('Invalid coverage.applicationIds.');
|
|
1134
|
-
|
|
1183
|
+
const event = await withStateLock('rounds', async (dir) => {
|
|
1135
1184
|
const round = await roundStatus(string(value.roundId, 'coverage.roundId', 180));
|
|
1136
1185
|
if (round.completed) throw new Error('Cannot record coverage for a completed round.');
|
|
1137
1186
|
const applications = await jsonLines(join(dir, 'applications.ndjson'));
|
|
@@ -1145,6 +1194,8 @@ async function roundSource(input) {
|
|
|
1145
1194
|
await chmod(file, 0o600);
|
|
1146
1195
|
return event;
|
|
1147
1196
|
});
|
|
1197
|
+
await appendCloudEvent('rounds', event);
|
|
1198
|
+
return event;
|
|
1148
1199
|
}
|
|
1149
1200
|
|
|
1150
1201
|
function replayAttention(events, roundId = null) {
|
|
@@ -1237,7 +1288,7 @@ async function roundComplete(input) {
|
|
|
1237
1288
|
const allowed = new Set(['roundId', 'completedAt', 'concentrationReason', 'concentrationEvidence']);
|
|
1238
1289
|
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown round completion property: ${key}.`);
|
|
1239
1290
|
// Use the same lock order as ledger writes so completion cannot race a confirmed submission.
|
|
1240
|
-
|
|
1291
|
+
const result = await withStateLock('applications', () => withStateLock('rounds', async (dir) => {
|
|
1241
1292
|
const status = await roundStatus(string(value.roundId, 'round.roundId', 180));
|
|
1242
1293
|
if (status.completed) return { ...status, completionRecorded: false };
|
|
1243
1294
|
if (status.confirmedCount < status.requestedCount) throw new Error(`Round requires ${status.requestedCount} confirmed submissions before completion.`);
|
|
@@ -1254,6 +1305,8 @@ async function roundComplete(input) {
|
|
|
1254
1305
|
await chmod(file, 0o600);
|
|
1255
1306
|
return { ...status, discovery: { ...status.discovery, ...explanation }, completed: true, completionRecorded: true, completedAt: event.occurredAt };
|
|
1256
1307
|
}));
|
|
1308
|
+
if (result.completionRecorded) await appendCloudEvent('rounds', { type: 'completed', roundId: result.roundId, occurredAt: result.completedAt, ...(result.discovery.concentrationReason ? { concentrationReason: result.discovery.concentrationReason, concentrationEvidence: result.discovery.concentrationEvidence } : {}) });
|
|
1309
|
+
return result;
|
|
1257
1310
|
}
|
|
1258
1311
|
|
|
1259
1312
|
async function frictionRecord(input) {
|
|
@@ -1330,6 +1383,14 @@ async function ledgerOutcome(outcomeInput) {
|
|
|
1330
1383
|
return { stored: true, enriched };
|
|
1331
1384
|
});
|
|
1332
1385
|
const applications = await jsonLines(join(await ensureStateDir(), 'applications.ndjson'));
|
|
1386
|
+
if (storage.stored && await cloudState.configured()) {
|
|
1387
|
+
await cloudState.appendRecord('outcomes', event, {
|
|
1388
|
+
recordKey: event.id,
|
|
1389
|
+
idempotencyKey: `outcome:${event.id}:${event.status}:${event.occurredAt}:${createHash('sha256').update(JSON.stringify(event)).digest('hex')}`,
|
|
1390
|
+
occurredAt: event.occurredAt,
|
|
1391
|
+
queueOnFailure: true,
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1333
1394
|
return { result: { recorded: storage.stored, duplicate: !storage.stored, enriched: storage.enriched, recordedOutcome: event.id, status }, application: applications.find((entry) => entry.id === event.id) ?? null, event };
|
|
1334
1395
|
}
|
|
1335
1396
|
|
|
@@ -1352,9 +1413,62 @@ async function ledgerReviewAcknowledge(input) {
|
|
|
1352
1413
|
await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
1353
1414
|
await chmod(file, 0o600);
|
|
1354
1415
|
});
|
|
1416
|
+
if (await cloudState.configured()) await cloudState.appendRecord('reviews', event, { recordKey: reviewedAt, idempotencyKey: `review:${reviewedAt}`, occurredAt: reviewedAt, queueOnFailure: true });
|
|
1355
1417
|
return { acknowledged: true, ...event };
|
|
1356
1418
|
}
|
|
1357
1419
|
|
|
1420
|
+
async function prepareCloudState(area, action) {
|
|
1421
|
+
if (!await cloudState.configured() || area === 'cloud') return;
|
|
1422
|
+
try {
|
|
1423
|
+
await cloudState.reconcile({ dryRun: false, provenance: 'automatic-recovery' });
|
|
1424
|
+
await cloudState.refreshDocumentCaches();
|
|
1425
|
+
} catch (error) {
|
|
1426
|
+
// New submissions still fail closed because lease and intent operations
|
|
1427
|
+
// call the cloud directly. Local checks, research, and observed-result
|
|
1428
|
+
// ledger appends can continue and queue their idempotent recovery writes.
|
|
1429
|
+
if (!/^Cloud state unavailable:/.test(error.message)) throw error;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
async function cloudCommand(action, value) {
|
|
1434
|
+
if (action === 'configure' && value === '--stdin') {
|
|
1435
|
+
const configured = await saveCloudConfig(await jsonStdin(), { configPath: process.env.JOB_APPLICATION_AGENT_CLOUD_CONFIG ?? defaultCloudConfigPath() });
|
|
1436
|
+
const updateGuard = await enableCloudUpdateGuard();
|
|
1437
|
+
return { configured: true, url: configured.url, clientId: configured.clientId ?? null, clientName: configured.clientName ?? null, token: configured.token, updateGuard };
|
|
1438
|
+
}
|
|
1439
|
+
if (action === 'status' && value == null) return cloudState.status();
|
|
1440
|
+
if (action === 'reconcile' && value === '--dry-run') return cloudState.reconcile({ dryRun: true });
|
|
1441
|
+
if (action === 'reconcile' && value == null) return cloudState.reconcile({ dryRun: false });
|
|
1442
|
+
if (action === 'export') return cloudState.exportTo(value ?? null);
|
|
1443
|
+
if (action === 'lease-acquire' && value == null) {
|
|
1444
|
+
const lease = await cloudState.acquireLease();
|
|
1445
|
+
await writePrivateJson(join(await ensureStateDir(), 'cloud-lease.json'), lease);
|
|
1446
|
+
return lease;
|
|
1447
|
+
}
|
|
1448
|
+
if (action === 'lease-renew' && value == null) {
|
|
1449
|
+
const lease = await readPrivateJson(join(await ensureStateDir(), 'cloud-lease.json'), null);
|
|
1450
|
+
if (!lease?.leaseId) throw new Error('No local cloud lease is recorded. Run cloud lease-acquire.');
|
|
1451
|
+
const renewed = await cloudState.renewLease(lease.leaseId);
|
|
1452
|
+
await writePrivateJson(join(await ensureStateDir(), 'cloud-lease.json'), renewed);
|
|
1453
|
+
return renewed;
|
|
1454
|
+
}
|
|
1455
|
+
if (action === 'lease-release' && value == null) {
|
|
1456
|
+
const lease = await readPrivateJson(join(await ensureStateDir(), 'cloud-lease.json'), null);
|
|
1457
|
+
if (!lease?.leaseId) throw new Error('No local cloud lease is recorded.');
|
|
1458
|
+
return cloudState.releaseLease(lease.leaseId);
|
|
1459
|
+
}
|
|
1460
|
+
if (action === 'intent-prepare' && value === '--stdin') return cloudState.createIntent(await jsonStdin());
|
|
1461
|
+
if (action === 'intent-sent' && value === '--stdin') {
|
|
1462
|
+
const input = await jsonStdin();
|
|
1463
|
+
return cloudState.markIntentSentUnverified(string(input.intentId, 'intentId', 200), string(input.leaseId, 'leaseId', 200));
|
|
1464
|
+
}
|
|
1465
|
+
if (action === 'intent-confirm' && value === '--stdin') {
|
|
1466
|
+
const input = await jsonStdin();
|
|
1467
|
+
return cloudState.confirmIntent(string(input.intentId, 'intentId', 200), object(input.application, 'application'), string(input.leaseId, 'leaseId', 200), input.idempotencyKey);
|
|
1468
|
+
}
|
|
1469
|
+
throw new Error('Usage: cloud status|configure --stdin|reconcile [--dry-run]|export [path]|lease-acquire|lease-renew|lease-release|intent-prepare --stdin|intent-sent --stdin|intent-confirm --stdin');
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1358
1472
|
function print(value) {
|
|
1359
1473
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
1360
1474
|
}
|
|
@@ -1414,7 +1528,8 @@ function roundCompletedTelemetry(round) {
|
|
|
1414
1528
|
async function executeCommand([area, action, value], telemetry, session, community) {
|
|
1415
1529
|
const domainEvents = [];
|
|
1416
1530
|
let result;
|
|
1417
|
-
if (area === '
|
|
1531
|
+
if (area === 'cloud') result = await cloudCommand(action, value);
|
|
1532
|
+
else if (area === 'profile' && action === 'set' && value === '--stdin') {
|
|
1418
1533
|
const profile = await jsonStdin();
|
|
1419
1534
|
result = await profileSet(profile);
|
|
1420
1535
|
} else if (area === 'profile' && action === 'migrate' && value === '--stdin') {
|
|
@@ -1436,7 +1551,7 @@ async function executeCommand([area, action, value], telemetry, session, communi
|
|
|
1436
1551
|
const input = await jsonStdin();
|
|
1437
1552
|
const telemetryDetails = validateSubmissionTelemetry(input.telemetry);
|
|
1438
1553
|
const entry = validateLedgerEntry(input);
|
|
1439
|
-
result = await ledgerAdd(entry, input.duplicateOverride, input.companyReapplyOverride);
|
|
1554
|
+
result = await ledgerAdd(entry, input.duplicateOverride, input.companyReapplyOverride, input.cloudIntentId && input.cloudLeaseId ? { intentId: input.cloudIntentId, leaseId: input.cloudLeaseId } : null);
|
|
1440
1555
|
result.communityJob = await communityJobsSync(community, { limit: 1, applicationIds: [entry.id] });
|
|
1441
1556
|
domainEvents.push(await telemetryApplicationSubmitted(entry, telemetryDetails));
|
|
1442
1557
|
} else if (area === 'ledger' && action === 'outcome' && value === '--stdin') {
|
|
@@ -1488,7 +1603,7 @@ async function executeCommand([area, action, value], telemetry, session, communi
|
|
|
1488
1603
|
else if (area === 'attention' && action === 'resolve' && value === '--stdin') result = await attentionResolve(await jsonStdin());
|
|
1489
1604
|
else if (area === 'friction' && action === 'record' && value === '--stdin') result = await frictionRecord(await jsonStdin());
|
|
1490
1605
|
else if (area === 'friction' && action === 'list' && value == null) result = await frictionList();
|
|
1491
|
-
else throw new Error('Usage: profile set|migrate --stdin; profile check|field <name>; resume import <url-or-pdf>|path; score --stdin; ledger check|add|outcome|review-ack --stdin; ledger review; autonomy grant --stdin|status|preview|revoke; round start|source|complete --stdin|status [round-id]; sources list [--stdin]|jobs [--stdin]|suggest --stdin|pending|sync|sharing status|enable|disable|reset; attention add|resolve --stdin|list; friction record --stdin|list; telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
1606
|
+
else throw new Error('Usage: cloud status|configure --stdin|reconcile [--dry-run]|export [path]|lease-acquire|lease-renew|lease-release|intent-prepare --stdin|intent-sent --stdin|intent-confirm --stdin; profile set|migrate --stdin; profile check|field <name>; resume import <url-or-pdf>|path; score --stdin; ledger check|add|outcome|review-ack --stdin; ledger review; autonomy grant --stdin|status|preview|revoke; round start|source|complete --stdin|status [round-id]; sources list [--stdin]|jobs [--stdin]|suggest --stdin|pending|sync|sharing status|enable|disable|reset; attention add|resolve --stdin|list; friction record --stdin|list; telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
1492
1607
|
for (const event of domainEvents) await telemetry.record(event, session);
|
|
1493
1608
|
return result;
|
|
1494
1609
|
}
|
|
@@ -1527,6 +1642,8 @@ async function main(args) {
|
|
|
1527
1642
|
throw new Error('Usage: telemetry status|enable|disable|reset|preview --stdin|record --stdin; telemetry identity status|enable|disable');
|
|
1528
1643
|
}
|
|
1529
1644
|
|
|
1645
|
+
await prepareCloudState(area, action);
|
|
1646
|
+
|
|
1530
1647
|
const command = commandCategory(args);
|
|
1531
1648
|
const session = await telemetry.beginCommand(command);
|
|
1532
1649
|
await recordInstallationStart(telemetry, session);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SKILL_VERSION = '3.4.
|
|
1
|
+
export const SKILL_VERSION = '3.4.2';
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
import { CloudStateClient, saveCloudConfig } from '../scripts/cloud-state-client.mjs';
|
|
8
|
+
import worker, { sha256Hex } from '../../state-worker/src/worker.mjs';
|
|
9
|
+
import { createMemoryD1, hasNodeSqlite } from '../../state-worker/tests/d1-mock.mjs';
|
|
10
|
+
import { createMemoryR2 } from '../../state-worker/tests/r2-mock.mjs';
|
|
11
|
+
|
|
12
|
+
const TOKEN = 'test-client-token-with-sufficient-length-cloud';
|
|
13
|
+
const sqliteTest = hasNodeSqlite ? test : test.skip;
|
|
14
|
+
|
|
15
|
+
async function setup() {
|
|
16
|
+
const root = await mkdtemp(join(tmpdir(), 'job-agent-cloud-'));
|
|
17
|
+
const stateDir = join(root, 'state');
|
|
18
|
+
const configPath = join(root, 'cloud', 'config.json');
|
|
19
|
+
await mkdir(stateDir, { recursive: true });
|
|
20
|
+
const schema = await readFile(new URL('../../state-worker/migrations/0001_private_state.sql', import.meta.url), 'utf8');
|
|
21
|
+
const DB = createMemoryD1(schema);
|
|
22
|
+
await DB.prepare('INSERT INTO clients (client_id, name, token_hash, created_at) VALUES (?, ?, ?, ?)')
|
|
23
|
+
.bind('client-test', 'Test Client', sha256Hex(TOKEN), new Date().toISOString()).run();
|
|
24
|
+
const bindings = { DB, STATE: createMemoryR2(), STATE_TOKEN: 'legacy', LEGACY_WRITES_DISABLED: '1' };
|
|
25
|
+
const fetchImpl = (input, init) => worker.fetch(new Request(input, init), bindings);
|
|
26
|
+
await saveCloudConfig({ version: 2, url: 'https://state.example.com', token: TOKEN, clientId: 'client-test', clientName: 'Test Client' }, { configPath });
|
|
27
|
+
const client = new CloudStateClient({ stateDir, configPath, fetchImpl });
|
|
28
|
+
return { root, stateDir, configPath, bindings, client };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
sqliteTest('cloud config and profile cache are owner-only and work without Keychain', async () => {
|
|
32
|
+
const ctx = await setup();
|
|
33
|
+
await ctx.client.putDocument('profile', { name: 'Ada', email: 'ada@example.com' }, 0);
|
|
34
|
+
const profile = await ctx.client.refreshProfileCache();
|
|
35
|
+
assert.equal(profile.name, 'Ada');
|
|
36
|
+
if (process.platform !== 'win32') assert.equal((await stat(ctx.configPath)).mode & 0o777, 0o600);
|
|
37
|
+
const cache = join(ctx.stateDir, 'cloud-profile-cache.json');
|
|
38
|
+
if (process.platform !== 'win32') assert.equal((await stat(cache)).mode & 0o777, 0o600);
|
|
39
|
+
assert.deepEqual(JSON.parse(await readFile(cache, 'utf8')), profile);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
sqliteTest('cloud documents refresh the owner-only local caches', async () => {
|
|
43
|
+
const ctx = await setup();
|
|
44
|
+
await ctx.client.putDocument('autonomy', { version: 1, enabled: true, mode: 'routine-auto' }, 0);
|
|
45
|
+
|
|
46
|
+
const refreshed = await ctx.client.refreshDocumentCaches();
|
|
47
|
+
|
|
48
|
+
assert.equal(refreshed.autonomy.revision, 1);
|
|
49
|
+
assert.equal(JSON.parse(await readFile(join(ctx.stateDir, 'autonomy.json'), 'utf8')).enabled, true);
|
|
50
|
+
if (process.platform !== 'win32') assert.equal((await stat(join(ctx.stateDir, 'autonomy.json'))).mode & 0o777, 0o600);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
sqliteTest('reconcile dry-run reports the exact union without writing', async () => {
|
|
54
|
+
const ctx = await setup();
|
|
55
|
+
const local = [
|
|
56
|
+
{ id: 'app-1', company: 'A', submittedAt: '2026-01-01T00:00:00.000Z' },
|
|
57
|
+
{ id: 'app-2', company: 'B', submittedAt: '2026-01-02T00:00:00.000Z' },
|
|
58
|
+
];
|
|
59
|
+
await writeFile(join(ctx.stateDir, 'applications.ndjson'), `${local.map(JSON.stringify).join('\n')}\n`);
|
|
60
|
+
await ctx.client.appendRecord('applications', local[0], { recordKey: 'app-1', idempotencyKey: 'migration:app-1' });
|
|
61
|
+
const report = await ctx.client.reconcile({ dryRun: true });
|
|
62
|
+
assert.deepEqual(report.streams.applications, { localRows: 2, cloudRows: 1, localOnly: 1, cloudOnly: 0, unionRows: 2 });
|
|
63
|
+
assert.equal((await ctx.bindings.DB.prepare("SELECT COUNT(*) AS count FROM records WHERE stream = 'applications'").first()).count, 1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
sqliteTest('reconcile imports local-only rows idempotently and preserves provenance', async () => {
|
|
67
|
+
const ctx = await setup();
|
|
68
|
+
const local = [{ id: 'app-1', company: 'A', submittedAt: '2026-01-01T00:00:00.000Z' }];
|
|
69
|
+
await writeFile(join(ctx.stateDir, 'applications.ndjson'), `${JSON.stringify(local[0])}\n`);
|
|
70
|
+
const first = await ctx.client.reconcile({ dryRun: false, provenance: 'mac-cutover' });
|
|
71
|
+
const second = await ctx.client.reconcile({ dryRun: false, provenance: 'mac-cutover' });
|
|
72
|
+
assert.equal(first.imported, 1);
|
|
73
|
+
assert.equal(second.imported, 0);
|
|
74
|
+
const row = await ctx.bindings.DB.prepare("SELECT provenance FROM records WHERE stream = 'applications'").first();
|
|
75
|
+
assert.equal(row.provenance, 'mac-cutover');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
sqliteTest('reconcile includes the owner-only discovery review ledger', async () => {
|
|
79
|
+
const ctx = await setup();
|
|
80
|
+
const lead = { type: 'lead-reviewed', roundId: 'round-1', leadId: 'lead-1', disposition: 'duplicate', occurredAt: '2026-01-01T00:00:00.000Z' };
|
|
81
|
+
await writeFile(join(ctx.stateDir, 'discovery.ndjson'), `${JSON.stringify(lead)}\n`);
|
|
82
|
+
|
|
83
|
+
const report = await ctx.client.reconcile({ dryRun: false });
|
|
84
|
+
|
|
85
|
+
assert.equal(report.streams.discovery.imported ?? report.streams.discovery.localOnly, 1);
|
|
86
|
+
assert.deepEqual((await ctx.client.listStream('discovery'))[0].value, lead);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
sqliteTest('resume download verifies checksum and creates a private path cache', async () => {
|
|
90
|
+
const ctx = await setup();
|
|
91
|
+
const bytes = Buffer.from('%PDF-1.7\ncloud-resume-fixture');
|
|
92
|
+
await ctx.client.putFile('resume.pdf', bytes, 0);
|
|
93
|
+
const result = await ctx.client.fetchResume();
|
|
94
|
+
assert.equal(result.sha256, sha256Hex(bytes));
|
|
95
|
+
if (process.platform !== 'win32') assert.equal((await stat(result.path)).mode & 0o777, 0o600);
|
|
96
|
+
assert.equal(Buffer.from(await readFile(result.path)).equals(bytes), true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
sqliteTest('outage queues an already observed append but blocks application intents', async () => {
|
|
100
|
+
const ctx = await setup();
|
|
101
|
+
const offline = new CloudStateClient({ stateDir: ctx.stateDir, configPath: ctx.configPath, fetchImpl: async () => { throw new Error('offline'); } });
|
|
102
|
+
const queued = await offline.appendRecord('outcomes', { id: 'app-1', status: 'interview' }, { recordKey: 'app-1', idempotencyKey: 'outcome:1', queueOnFailure: true });
|
|
103
|
+
assert.equal(queued.queued, true);
|
|
104
|
+
await assert.rejects(() => offline.createIntent({ applicationId: 'app-2', canonicalUrl: 'https://jobs.example/app-2', leaseId: 'lease' }), /cloud state unavailable/i);
|
|
105
|
+
});
|
|
@@ -53,6 +53,7 @@ function isolatedCliEnv(directory) {
|
|
|
53
53
|
return {
|
|
54
54
|
...process.env,
|
|
55
55
|
JOB_APPLICATION_AGENT_STATE_DIR: directory,
|
|
56
|
+
JOB_APPLICATION_AGENT_CLOUD_CONFIG: join(directory, 'cloud-config.json'),
|
|
56
57
|
JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL: 'http://127.0.0.1:9',
|
|
57
58
|
};
|
|
58
59
|
}
|
|
@@ -89,6 +90,24 @@ test('returns the canonical resume path for direct browser uploads', async (t) =
|
|
|
89
90
|
assert.deepEqual(result, { path: resume });
|
|
90
91
|
});
|
|
91
92
|
|
|
93
|
+
test('returns a verified local resume cache while configured cloud storage is offline', async (t) => {
|
|
94
|
+
const directory = await mkdtemp(join(tmpdir(), 'public-job-agent-offline-resume-'));
|
|
95
|
+
const cloudDirectory = await mkdtemp(join(tmpdir(), 'public-job-agent-offline-cloud-'));
|
|
96
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
97
|
+
t.after(() => rm(cloudDirectory, { recursive: true, force: true }));
|
|
98
|
+
const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
|
|
99
|
+
const resume = join(directory, 'resume.pdf');
|
|
100
|
+
const configPath = join(cloudDirectory, 'config.json');
|
|
101
|
+
await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
|
|
102
|
+
await writeFile(resume, '%PDF-1.7\ncached resume fixture');
|
|
103
|
+
await writeFile(configPath, JSON.stringify({ version: 2, url: 'https://127.0.0.1:9', token: 'offline-client-token-with-sufficient-length', clientId: 'offline-client' }), { mode: 0o600 });
|
|
104
|
+
const env = { ...isolatedCliEnv(directory), JOB_APPLICATION_AGENT_CLOUD_CONFIG: configPath };
|
|
105
|
+
|
|
106
|
+
const result = JSON.parse(execFileSync(process.execPath, [script, 'resume', 'path'], { env, encoding: 'utf8' }));
|
|
107
|
+
|
|
108
|
+
assert.deepEqual(result, { path: resume });
|
|
109
|
+
});
|
|
110
|
+
|
|
92
111
|
test('preserves the Linux secret-tool install error for profile-dependent commands', async (t) => {
|
|
93
112
|
const directory = await mkdtemp(join(tmpdir(), 'job-agent-linux-profile-error-'));
|
|
94
113
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
@@ -18,6 +18,7 @@ test('Linux Secret Service supports the real profile CLI lifecycle', { skip: !en
|
|
|
18
18
|
const env = {
|
|
19
19
|
...process.env,
|
|
20
20
|
JOB_APPLICATION_AGENT_KEYCHAIN_SERVICE: service,
|
|
21
|
+
JOB_APPLICATION_AGENT_CLOUD_CONFIG: join(directory, 'cloud-config.json'),
|
|
21
22
|
JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL: 'http://127.0.0.1:9',
|
|
22
23
|
JOB_APPLICATION_AGENT_STATE_DIR: directory,
|
|
23
24
|
};
|
|
@@ -20,7 +20,7 @@ async function fixture(t, label) {
|
|
|
20
20
|
graceConsumed: true,
|
|
21
21
|
installationEventPending: false,
|
|
22
22
|
}));
|
|
23
|
-
return { directory, env: { ...process.env, JOB_APPLICATION_AGENT_STATE_DIR: directory, JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL: 'http://127.0.0.1:9' } };
|
|
23
|
+
return { directory, env: { ...process.env, JOB_APPLICATION_AGENT_STATE_DIR: directory, JOB_APPLICATION_AGENT_CLOUD_CONFIG: join(directory, 'cloud-config.json'), JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL: 'http://127.0.0.1:9' } };
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
function cli(env, args, input) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "job-application-agent",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.2",
|
|
4
4
|
"description": "A privacy-first Agent Skill and CLI for evidence-based job discovery, application completion, and outcome tracking.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,9 +19,15 @@
|
|
|
19
19
|
"skill",
|
|
20
20
|
"job-search",
|
|
21
21
|
"job-application",
|
|
22
|
-
"career"
|
|
22
|
+
"career",
|
|
23
|
+
"privacy",
|
|
24
|
+
"claude",
|
|
25
|
+
"cursor",
|
|
26
|
+
"codex",
|
|
27
|
+
"resume",
|
|
28
|
+
"browser-automation"
|
|
23
29
|
],
|
|
24
|
-
"homepage": "https://
|
|
30
|
+
"homepage": "https://jobappagent.com",
|
|
25
31
|
"repository": {
|
|
26
32
|
"type": "git",
|
|
27
33
|
"url": "git+https://github.com/vaibhavarora14/job-application-agent.git"
|
|
@@ -38,7 +44,7 @@
|
|
|
38
44
|
"check:native-artifacts": "node scripts/ci/validate-native-artifacts.mjs",
|
|
39
45
|
"privacy-audit": "node --test job-application-agent/tests/privacy-audit.test.mjs",
|
|
40
46
|
"smoke:package": "node scripts/smoke-package.mjs",
|
|
41
|
-
"check": "node --check job-application-agent/scripts/job-application.mjs && node --check job-application-agent/scripts/secret-store.mjs && node --check job-application-agent/scripts/source-community-client.mjs && node --check job-application-agent/scripts/source-community-schema.mjs && node --check job-application-agent/scripts/telemetry-client.mjs && node --check job-application-agent/scripts/telemetry-schema.mjs && node --check job-application-agent/scripts/version.mjs && node --check telemetry-worker/src/worker.mjs && node --check telemetry-worker/src/public-stats.mjs && node --check telemetry-worker/public/dashboard.js && node --check installer/src/cli.mjs && node --check installer/src/installer.mjs && node --check installer/src/runner.mjs && node --check installer/src/scheduler.mjs && node --check scripts/ci/classify-paths.mjs && node --check scripts/ci/validate-native-artifacts.mjs && node --check bin/job-application-agent.mjs && node --check state-worker/src/worker.mjs && node --check state-worker/src/cli.mjs && node --check state-worker/bin/sync.mjs",
|
|
47
|
+
"check": "node --check job-application-agent/scripts/job-application.mjs && node --check job-application-agent/scripts/cloud-state-client.mjs && node --check job-application-agent/scripts/secret-store.mjs && node --check job-application-agent/scripts/source-community-client.mjs && node --check job-application-agent/scripts/source-community-schema.mjs && node --check job-application-agent/scripts/telemetry-client.mjs && node --check job-application-agent/scripts/telemetry-schema.mjs && node --check job-application-agent/scripts/version.mjs && node --check telemetry-worker/src/worker.mjs && node --check telemetry-worker/src/public-stats.mjs && node --check telemetry-worker/public/dashboard.js && node --check installer/src/cli.mjs && node --check installer/src/installer.mjs && node --check installer/src/runner.mjs && node --check installer/src/scheduler.mjs && node --check scripts/ci/classify-paths.mjs && node --check scripts/ci/validate-native-artifacts.mjs && node --check bin/job-application-agent.mjs && node --check state-worker/src/worker.mjs && node --check state-worker/src/backup.mjs && node --check state-worker/src/cli.mjs && node --check state-worker/bin/sync.mjs",
|
|
42
48
|
"prepack": "npm run check && npm test && npm run privacy-audit"
|
|
43
49
|
},
|
|
44
50
|
"engines": {
|