@cohortapp/agent-sdk 2.5.1 → 2.6.1
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/bin/maestro.mjs +305 -89
- package/bin/maestro.test.mjs +357 -48
- package/docs/runbooks/backup-restore.md +65 -33
- package/framework-features.json +4 -4
- package/lib/backup/policy.mjs +710 -0
- package/lib/backup/policy.test.mjs +305 -0
- package/lib/budget-escalate.mjs +133 -0
- package/lib/budget-escalate.test.mjs +232 -0
- package/lib/budget-guard.envelope.test.mjs +476 -0
- package/lib/budget-guard.mjs +853 -75
- package/lib/budget-guard.test.mjs +91 -42
- package/lib/cadences.mjs +33 -0
- package/lib/channels/orgmail/adapter.mjs +88 -3
- package/lib/channels/orgmail/adapter.test.mjs +137 -0
- package/lib/channels/repeat-suppressor.mjs +198 -0
- package/lib/channels/repeat-suppressor.test.mjs +134 -0
- package/lib/comms/receipts.mjs +297 -0
- package/lib/cost/ledger-row.mjs +333 -0
- package/lib/cost/ledger-row.test.mjs +183 -0
- package/lib/execution/drive.mjs +28 -1
- package/lib/execution/effects.mjs +191 -12
- package/lib/execution/effects.test.mjs +50 -11
- package/lib/goals/admission.mjs +13 -1
- package/lib/goals/admission.test.mjs +26 -1
- package/lib/goals/loop.mjs +13 -0
- package/lib/kpi-sensors.test.mjs +3 -0
- package/lib/mandate/cache.mjs +13 -5
- package/lib/mandate/derive.mjs +146 -21
- package/lib/mandate/derive.test.mjs +50 -6
- package/lib/mandate/model.mjs +32 -4
- package/lib/mandate/refresh.test.mjs +16 -2
- package/lib/mcp/server.test.mjs +12 -3
- package/lib/model-router/economics.mjs +107 -76
- package/lib/model-router/economics.test.mjs +64 -46
- package/lib/model-router/integration-coverage.test.mjs +39 -37
- package/lib/model-router/ledger.mjs +75 -22
- package/lib/model-router/ledger.test.mjs +35 -2
- package/lib/org/client.mjs +14 -0
- package/lib/org/cost-sync.mjs +16 -2
- package/lib/org/doctor.mjs +62 -1
- package/lib/org/doctor.test.mjs +36 -3
- package/lib/org/email-remedy.mjs +49 -0
- package/lib/org/engagement-ledger.mjs +376 -0
- package/lib/org/engagement-ledger.test.mjs +112 -0
- package/lib/org/engagement.mjs +1056 -0
- package/lib/org/engagement.test.mjs +739 -0
- package/lib/org/messaging.mjs +230 -3
- package/lib/org/messaging.test.mjs +110 -1
- package/lib/org/param-contract.mjs +56 -2
- package/lib/org/param-contract.test.mjs +26 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +5 -0
- package/lib/org/protocol.test.mjs +7 -1
- package/lib/org/tool-surface.mjs +506 -10
- package/lib/org/tool-surface.test.mjs +191 -7
- package/lib/org/ui-parity.mjs +333 -6
- package/lib/org/ui-parity.test.mjs +96 -3
- package/lib/org/work-ledger.mjs +241 -0
- package/lib/org/work-ledger.test.mjs +237 -0
- package/lib/plan/adoption-e2e.test.mjs +366 -0
- package/lib/plan/budget-enforcement.test.mjs +400 -0
- package/lib/plan/budget-runtime.mjs +215 -0
- package/lib/plan/compile.mjs +201 -5
- package/lib/plan/compile.test.mjs +19 -5
- package/lib/plan/emit.mjs +8 -0
- package/lib/plan/emit.test.mjs +18 -0
- package/lib/resource-governor.mjs +58 -12
- package/lib/resource-governor.test.mjs +41 -1
- package/lib/security/audit-engine.mjs +45 -8
- package/lib/security/audit-engine.test.mjs +35 -0
- package/lib/setup/enroll-from-cohort.mjs +14 -1
- package/lib/setup/sections/mandate.mjs +48 -7
- package/lib/setup/sections/mandate.test.mjs +17 -2
- package/lib/setup/sections/orgmail.mjs +10 -2
- package/lib/setup/state.mjs +83 -2
- package/lib/telemetry/collect.mjs +360 -20
- package/lib/telemetry/collect.test.mjs +266 -0
- package/package.json +1 -1
- package/scripts/cost/track-claude-usage.mjs +207 -48
- package/scripts/cost/track-claude-usage.test.mjs +148 -0
- package/scripts/daemon/agent-daemon.mjs +315 -17
- package/scripts/daemon/assurance-e2e.test.mjs +421 -0
- package/scripts/daemon/assurance.mjs +944 -0
- package/scripts/daemon/assurance.test.mjs +668 -0
- package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
- package/scripts/daemon/cadence-consumer.mjs +147 -9
- package/scripts/daemon/cadence-consumer.test.mjs +6 -0
- package/scripts/daemon/cadence-handlers.mjs +158 -0
- package/scripts/daemon/cadence-handlers.test.mjs +64 -0
- package/scripts/daemon/classifier.test.mjs +18 -9
- package/scripts/daemon/deliver.mjs +314 -0
- package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
- package/scripts/daemon/dispatcher.mjs +64 -6
- package/scripts/daemon/responder-cost.test.mjs +68 -0
- package/scripts/daemon/responder.mjs +351 -298
- package/scripts/local-triggers/generate-plists.test.mjs +7 -4
- package/scripts/maintenance/backup-run.mjs +415 -0
- package/scripts/maintenance/backup-to-cloud.sh +16 -116
- package/scripts/org/send-orgmail.mjs +16 -0
- package/scripts/record-receipt.sh +63 -0
- package/scripts/restore-from-backup.sh +14 -3
- package/scripts/restore-from-backup.test.mjs +8 -5
- package/scripts/send-email-threaded.py +47 -0
- package/scripts/send-sms.sh +4 -0
- package/scripts/send-whatsapp.sh +4 -0
- package/scripts/setup/init-backup.mjs +93 -38
- package/scripts/slack-send.sh +12 -0
|
@@ -218,9 +218,19 @@ export function mapProfileToAgentConfig(profile) {
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
/**
|
|
221
|
-
* Map a CharterDTO ({ approvedByLine, gatesText, sections:[{kind,order,title,body}] })
|
|
221
|
+
* Map a CharterDTO ({ approvedByLine, gatesText, sections:[{id,kind,order,title,body}] })
|
|
222
222
|
* into the persisted charter subset. Returns null when there is no real charter
|
|
223
223
|
* (no sections AND no approvedBy/gates text) so an empty patch stays empty.
|
|
224
|
+
*
|
|
225
|
+
* `id` IS CARRIED, and it is load-bearing. This projection is the ONLY writer of
|
|
226
|
+
* `config/agent.json` charter.sections, and it used to drop the field — so every
|
|
227
|
+
* section on every enrolled seat had keys `[body,kind,order,title]` only,
|
|
228
|
+
* `lib/mandate/derive.mjs#charterSectionId` read `s.id` as undefined, and every
|
|
229
|
+
* proposal reached hq with null charter provenance. hq's `propose` gate then
|
|
230
|
+
* refuses the whole slate for "traces to no CharterSection", which reads as a
|
|
231
|
+
* governance decision and is actually a dropped column three modules upstream.
|
|
232
|
+
* The id is hq's `CharterSection.id`; it is a join key, not content.
|
|
233
|
+
*
|
|
224
234
|
* @param {object|null} charter
|
|
225
235
|
* @returns {object|null}
|
|
226
236
|
*/
|
|
@@ -230,6 +240,9 @@ function mapCharter(charter) {
|
|
|
230
240
|
const sections = sectionsIn
|
|
231
241
|
.filter((s) => s && typeof s === "object")
|
|
232
242
|
.map((s) => ({
|
|
243
|
+
// Omitted rather than nulled when hq did not send one, so a charter fetched
|
|
244
|
+
// from an older hq stays byte-identical to what it was before.
|
|
245
|
+
...(str(s.id) ? { id: str(s.id) } : {}),
|
|
233
246
|
kind: str(s.kind),
|
|
234
247
|
order: Number.isFinite(s.order) ? s.order : 0,
|
|
235
248
|
title: str(s.title) || null,
|
|
@@ -198,19 +198,54 @@ export async function resolveMandate(o = {}, deps = {}) {
|
|
|
198
198
|
// call adopt, from anywhere, for any reason.
|
|
199
199
|
const proposals = res.source === "local" ? allObjectives(res.record) : [];
|
|
200
200
|
if (typeof call === "function" && proposals.length) {
|
|
201
|
+
// PILLARS FIRST, then their children. hq's `mandate.propose` takes
|
|
202
|
+
// `parentId` and `charterSectionId` — real row ids — and has never had any
|
|
203
|
+
// idea what a `parentKey` is. This loop used to post `parentKey`, which the
|
|
204
|
+
// dispatcher discarded as an unknown param, so EVERY proposal reached hq
|
|
205
|
+
// with `parentId: null` AND `charterSectionId: null`. That is the whole
|
|
206
|
+
// reason "0 of 35 adopted" looked like a missing human click: all 35 rows
|
|
207
|
+
// were unadoptable on arrival, and nothing on either side said so.
|
|
208
|
+
const order = { PILLAR: 0, OBJECTIVE: 1, GOAL: 2 };
|
|
209
|
+
const ordered = proposals
|
|
210
|
+
.slice()
|
|
211
|
+
.sort((a, b) => (order[String(a.kind).toUpperCase()] ?? 3) - (order[String(b.kind).toUpperCase()] ?? 3));
|
|
212
|
+
/** local key → the row id hq assigned it, so children can cite a real parent. */
|
|
213
|
+
const idByKey = new Map();
|
|
201
214
|
let posted = 0;
|
|
202
|
-
|
|
215
|
+
const refusals = [];
|
|
216
|
+
for (const obj of ordered) {
|
|
217
|
+
const parentId = obj.parentKey ? idByKey.get(obj.parentKey) || null : null;
|
|
218
|
+
if (obj.parentKey && !parentId) {
|
|
219
|
+
// The parent pillar itself failed to post. Proposing the child anyway
|
|
220
|
+
// would recreate the exact defect above.
|
|
221
|
+
refusals.push(`${obj.key}: its parent "${obj.parentKey}" is not on the server`);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
203
224
|
try {
|
|
204
225
|
const r = await call("mandate.propose", {
|
|
205
226
|
key: obj.key, kind: obj.kind, text: obj.text, metric: obj.metric, unit: obj.unit,
|
|
206
227
|
direction: obj.direction, target: obj.target, tolerance: obj.tolerance,
|
|
207
|
-
cadence: obj.cadence, sensor: obj.sensor,
|
|
228
|
+
cadence: obj.cadence, sensor: obj.sensor,
|
|
229
|
+
parentId,
|
|
230
|
+
charterSectionId: obj.charterSectionId || null,
|
|
208
231
|
});
|
|
209
|
-
if (r && r.ok)
|
|
210
|
-
|
|
232
|
+
if (r && r.ok) {
|
|
233
|
+
posted++;
|
|
234
|
+
const id = (r.result && r.result.id) || r.id || null;
|
|
235
|
+
if (id) idByKey.set(obj.key, id);
|
|
236
|
+
} else {
|
|
237
|
+
refusals.push(`${obj.key}: ${(r && r.error && r.error.message) || "hq refused it"}`);
|
|
238
|
+
}
|
|
239
|
+
} catch (e) {
|
|
240
|
+
// FAIL-OPEN IS FINE; SILENT IS NOT. This `catch {}` used to be empty,
|
|
241
|
+
// which is how a fleet-wide provenance bug stayed invisible: every
|
|
242
|
+
// proposal was refused and the only symptom was a count nobody read.
|
|
243
|
+
refusals.push(`${obj.key}: ${e && e.message ? e.message : e}`);
|
|
244
|
+
}
|
|
211
245
|
}
|
|
212
|
-
if (posted !==
|
|
213
|
-
log(`[mandate] proposed ${posted}/${
|
|
246
|
+
if (posted !== ordered.length) {
|
|
247
|
+
log(`[mandate] proposed ${posted}/${ordered.length} objectives to hq — the rest stayed local (the plan compiles from the cache either way)`);
|
|
248
|
+
for (const r of refusals) log(`[mandate] refused → ${r}`);
|
|
214
249
|
}
|
|
215
250
|
}
|
|
216
251
|
|
|
@@ -316,7 +351,13 @@ export default {
|
|
|
316
351
|
record = res.record;
|
|
317
352
|
if (res.derived) {
|
|
318
353
|
const c = res.derived.counts;
|
|
319
|
-
|
|
354
|
+
// ADOPTABLE, not just proposed. "35 proposed" is the number that let a
|
|
355
|
+
// seat sit at zero adopted for months and read as a missing human
|
|
356
|
+
// click; "35 proposed / 4 adoptable / 31 refused" is the number that
|
|
357
|
+
// says whose problem it is. hq refuses the other 31 at `mandate.adopt`
|
|
358
|
+
// regardless — this is where an operator finds that out in one line
|
|
359
|
+
// instead of one 400 at a time.
|
|
360
|
+
io.note(`[mandate] derived ${c.proposed} PROPOSED objectives (${c.pillars} pillar · ${c.objectives} objective · ${c.goals} goal; ${c.withMethodSensor} with a method sensor). ${c.adoptable} are ADOPTABLE today${c.refused ? `; ${c.refused} carry unresolved provenance/duplicate debt` : ""}. Adopt them in Cohort to activate them.`);
|
|
320
361
|
}
|
|
321
362
|
}
|
|
322
363
|
|
|
@@ -131,9 +131,21 @@ test("apply derives a PROPOSED mandate and caches it (nothing adopted by the age
|
|
|
131
131
|
assert.equal(cache.source, "local");
|
|
132
132
|
assert.ok(cache.body.objectives.length > 0);
|
|
133
133
|
assert.ok(cache.body.objectives.every((o) => o.state === "proposed"), "the agent NEVER adopts its own objectives");
|
|
134
|
-
// charter pillars became PILLAR nodes, and
|
|
134
|
+
// charter pillars became PILLAR nodes, and a PLACEABLE objective hangs off one
|
|
135
135
|
assert.equal(cache.body.objectives.filter((o) => o.kind === "PILLAR").length, 2);
|
|
136
|
-
|
|
136
|
+
const placed = cache.body.objectives.filter((o) => o.kind === "OBJECTIVE" && o.parentKey);
|
|
137
|
+
assert.ok(placed.length > 0, "at least one objective traces to a charter pillar");
|
|
138
|
+
for (const o of placed) {
|
|
139
|
+
// The clause id rides on the ROW, not only on its parent — `mandate.propose`
|
|
140
|
+
// takes `charterSectionId`, and a node carrying provenance only through
|
|
141
|
+
// `parentKey` reached hq with both provenance columns null.
|
|
142
|
+
assert.ok(o.charterSectionId, `"${o.key}" must carry the charter clause id it inherited`);
|
|
143
|
+
}
|
|
144
|
+
// An objective the deriver could NOT place is carried unplaced and named —
|
|
145
|
+
// never auto-attached to whichever pillar happened to be first.
|
|
146
|
+
for (const o of cache.body.objectives.filter((x) => x.kind === "OBJECTIVE" && !x.parentKey)) {
|
|
147
|
+
assert.equal(o.charterSectionId, null);
|
|
148
|
+
}
|
|
137
149
|
});
|
|
138
150
|
|
|
139
151
|
test("apply with only PROPOSED objectives emits EXACTLY today's cadence set — no schedule growth", async () => {
|
|
@@ -162,6 +174,9 @@ test("an ADOPTED objective grows the schedule: a real measure cadence lands in t
|
|
|
162
174
|
key: "pipeline-coverage", id: "obj_7Kx", kind: "OBJECTIVE", text: "Pipeline coverage", state: "active",
|
|
163
175
|
metric: "pipeline_coverage_x", direction: "up", target: 3, tolerance: 0.2, weight: 1, cadence: "weekly",
|
|
164
176
|
charterSectionId: "cs_1", sensor: { capability: "crm_list_deals", params: { stage: "open" }, source: "method" },
|
|
177
|
+
// hq stamps this on every adopt; the compiler REQUIRES it (an active row
|
|
178
|
+
// with no adopter on record compiles nothing — the law fails closed).
|
|
179
|
+
adoptedById: "mem_A03",
|
|
165
180
|
collaborators: [{ member_id: "mem_A03", role: "REVIEWER" }],
|
|
166
181
|
}],
|
|
167
182
|
},
|
|
@@ -32,6 +32,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
|
32
32
|
import * as io from "../io.mjs";
|
|
33
33
|
import { isEnabled, configFromAgent, loadOrgConfig } from "../../org/client.mjs";
|
|
34
34
|
import { emailInbox } from "../../org/ui-parity.mjs";
|
|
35
|
+
import { remedyFor } from "../../org/email-remedy.mjs";
|
|
35
36
|
|
|
36
37
|
const GATE_REL = join("config", "orgmail.yaml");
|
|
37
38
|
|
|
@@ -152,13 +153,15 @@ export default {
|
|
|
152
153
|
// Cohort → Settings → Email — warn, never fail the wizard on it.
|
|
153
154
|
let detail = "";
|
|
154
155
|
let reachable = false;
|
|
156
|
+
let probeCode = "";
|
|
155
157
|
try {
|
|
156
158
|
const r = await emailInbox({ limit: 1 }, { base: orgCfg.base, token: orgCfg.token, orgId: orgCfg.orgId, fetchImpl: ctx.fetchImpl });
|
|
157
159
|
if (r && r.ok) {
|
|
158
160
|
reachable = true;
|
|
159
161
|
detail = r.result?.mailbox?.address ? `mailbox ${r.result.mailbox.address}` : "mailbox reachable";
|
|
160
162
|
} else {
|
|
161
|
-
|
|
163
|
+
probeCode = r?.error?.code || "?";
|
|
164
|
+
detail = `${probeCode}: ${r?.error?.message || "unreachable"}`;
|
|
162
165
|
}
|
|
163
166
|
} catch {
|
|
164
167
|
detail = "probe failed";
|
|
@@ -166,7 +169,12 @@ export default {
|
|
|
166
169
|
checks.push({
|
|
167
170
|
name: reachable ? `Cohort mailbox reachable (${detail})` : "Cohort mailbox reachability (best-effort)",
|
|
168
171
|
ok: true, // never hard-fail — the admin may not have verified a domain yet
|
|
169
|
-
|
|
172
|
+
// The remedy comes from the ONE classifier the doctor and the adapter also
|
|
173
|
+
// use, so an operator is told the same thing by whichever surface they hit
|
|
174
|
+
// first — and is told an ACTOR and an ACT, not just a state.
|
|
175
|
+
remedy: reachable
|
|
176
|
+
? undefined
|
|
177
|
+
: `email.inbox probe: ${detail}. ${remedyFor(probeCode) || "Retry; if it persists, a workspace ADMIN checks Cohort → Settings → Email"}`,
|
|
170
178
|
});
|
|
171
179
|
return { ok: checks.every((c) => c.ok), checks };
|
|
172
180
|
},
|
package/lib/setup/state.mjs
CHANGED
|
@@ -32,6 +32,60 @@ import { createHash } from "node:crypto";
|
|
|
32
32
|
export const PROGRESS_REL = "state/setup/progress.json";
|
|
33
33
|
export const SCHEMA_VERSION = 1;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Answer keys whose VALUES are live credentials and must never be persisted.
|
|
37
|
+
*
|
|
38
|
+
* This checkpoint is a 0644 JSON file under `state/`, which is inside the
|
|
39
|
+
* default backup include set — so `orgToken` (the seat's COHORT_API_KEY,
|
|
40
|
+
* collected by `lib/setup/sections/org.mjs` via `io.secret`) was written here in
|
|
41
|
+
* plaintext and shipped, byte-identical to `.env`, inside every nightly archive.
|
|
42
|
+
* The backup deny-list matched PATHS only, so `.env` was correctly excluded
|
|
43
|
+
* while the same secret rode along inside an included file and the runner logged
|
|
44
|
+
* "archive verified clean". Set `offsite.provider` and it uploads nightly.
|
|
45
|
+
*
|
|
46
|
+
* The checkpoint is a RESUME HINT, never an authority: the wizard re-`detect`s
|
|
47
|
+
* every section on resume and the real credential lives in `.env` / the
|
|
48
|
+
* keychain, so dropping the value costs nothing but a re-prompt.
|
|
49
|
+
*
|
|
50
|
+
* Matched on the key NAME (narrow, deliberate patterns — `orgAuthMethod` must
|
|
51
|
+
* NOT match, or resume breaks) rather than on value shape, so a rotated token
|
|
52
|
+
* with a new prefix is still caught.
|
|
53
|
+
*/
|
|
54
|
+
export const SECRET_ANSWER_KEY_RE =
|
|
55
|
+
/(^|[._-])(token|secret|password|passphrase|credential|api[._-]?key|private[._-]?key|pairing[._-]?code)($|[._-])|token$|secret$|apikey$|pairingcode$/i;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Strip credential-valued answers. The key is DELETED, not placeholdered: every
|
|
59
|
+
* consumer resolves `answers.x != null ? answers.x : process.env.X` (see
|
|
60
|
+
* `lib/setup/sections/org.mjs#connFrom`), so a placeholder string would be
|
|
61
|
+
* handed to hq as a bearer token on resume. Absent is the only correct shape —
|
|
62
|
+
* it routes the read back to `.env` / the keychain, which is where the value
|
|
63
|
+
* actually lives.
|
|
64
|
+
*
|
|
65
|
+
* The dropped key names are recorded on `_redacted` so the file still SAYS what
|
|
66
|
+
* was removed. Names are not secrets; values are.
|
|
67
|
+
*
|
|
68
|
+
* @param {object|null|undefined} answers
|
|
69
|
+
* @returns {{answers:object, redacted:string[]}}
|
|
70
|
+
*/
|
|
71
|
+
export function redactSecretAnswers(answers) {
|
|
72
|
+
const src = answers && typeof answers === "object" ? answers : {};
|
|
73
|
+
const out = {};
|
|
74
|
+
const redacted = [];
|
|
75
|
+
for (const [k, v] of Object.entries(src)) {
|
|
76
|
+
if (k === "_redacted") continue;
|
|
77
|
+
if (SECRET_ANSWER_KEY_RE.test(k) && typeof v === "string" && v) {
|
|
78
|
+
redacted.push(k);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
out[k] = v;
|
|
82
|
+
}
|
|
83
|
+
const prior = Array.isArray(src._redacted) ? src._redacted : [];
|
|
84
|
+
const all = [...new Set([...prior, ...redacted])].sort();
|
|
85
|
+
if (all.length) out._redacted = all;
|
|
86
|
+
return { answers: out, redacted };
|
|
87
|
+
}
|
|
88
|
+
|
|
35
89
|
/** Absolute path to the progress file for an agent root. */
|
|
36
90
|
export function progressPath(agentRoot) {
|
|
37
91
|
return join(agentRoot, PROGRESS_REL);
|
|
@@ -78,10 +132,36 @@ export function loadProgress(agentRoot) {
|
|
|
78
132
|
try {
|
|
79
133
|
const raw = JSON.parse(readFileSync(p, "utf-8"));
|
|
80
134
|
if (!raw || typeof raw !== "object") return emptyProgress();
|
|
135
|
+
const { answers, redacted } = redactSecretAnswers(
|
|
136
|
+
raw.answers && typeof raw.answers === "object" ? raw.answers : {}
|
|
137
|
+
);
|
|
138
|
+
// SELF-HEAL, LOUDLY. Files written before this guard hold a live
|
|
139
|
+
// COHORT_API_KEY in plaintext, and they are inside the backup include set.
|
|
140
|
+
// Rewriting on the first read scrubs every enrolled agent the moment
|
|
141
|
+
// anything touches the checkpoint, instead of waiting for the next setup
|
|
142
|
+
// run. The credential is NOT rotated for you — say so.
|
|
143
|
+
if (redacted.length > 0) {
|
|
144
|
+
try {
|
|
145
|
+
const cleaned = { ...raw, answers };
|
|
146
|
+
const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
|
|
147
|
+
writeFileSync(tmp, JSON.stringify(cleaned, null, 2) + "\n", "utf-8");
|
|
148
|
+
renameSync(tmp, p);
|
|
149
|
+
console.warn(
|
|
150
|
+
`[setup/state] ${PROGRESS_REL} held credential value(s) in plaintext (${redacted.join(", ")}) — ` +
|
|
151
|
+
"scrubbed in place. This file is inside the backup include set, so treat those credentials as " +
|
|
152
|
+
"EXPOSED to every archive taken since enrolment and ROTATE them."
|
|
153
|
+
);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.error(
|
|
156
|
+
`[setup/state] ${PROGRESS_REL} holds credential value(s) in plaintext (${redacted.join(", ")}) and could ` +
|
|
157
|
+
`not be scrubbed (${err && err.message ? err.message : err}) — delete the file and rotate the credential.`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
81
161
|
return {
|
|
82
162
|
version: raw.version || SCHEMA_VERSION,
|
|
83
163
|
updatedAt: raw.updatedAt || null,
|
|
84
|
-
answers
|
|
164
|
+
answers,
|
|
85
165
|
sections: raw.sections && typeof raw.sections === "object" ? raw.sections : {},
|
|
86
166
|
enrich: raw.enrich && typeof raw.enrich === "object" ? raw.enrich : {},
|
|
87
167
|
};
|
|
@@ -98,7 +178,8 @@ export function loadProgress(agentRoot) {
|
|
|
98
178
|
export function saveProgress(agentRoot, progress) {
|
|
99
179
|
const p = progressPath(agentRoot);
|
|
100
180
|
mkdirSync(dirname(p), { recursive: true });
|
|
101
|
-
const
|
|
181
|
+
const merged = { ...emptyProgress(), ...progress, version: SCHEMA_VERSION, updatedAt: new Date().toISOString() };
|
|
182
|
+
const body = { ...merged, answers: redactSecretAnswers(merged.answers).answers };
|
|
102
183
|
const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
|
|
103
184
|
writeFileSync(tmp, JSON.stringify(body, null, 2) + "\n", "utf-8");
|
|
104
185
|
renameSync(tmp, p);
|