fullstackgtm 0.48.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- package/CONTRIBUTING.md +23 -4
- package/DATA-FLOWS.md +8 -2
- package/INSTALL_FOR_AGENTS.md +13 -4
- package/README.md +53 -1
- package/SECURITY.md +27 -3
- package/dist/acquireCheckpoint.d.ts +33 -0
- package/dist/acquireCheckpoint.js +149 -0
- package/dist/acquireLinkedIn.d.ts +2 -0
- package/dist/acquireLinkedIn.js +1 -1
- package/dist/cli/audit.js +10 -7
- package/dist/cli/auth.js +8 -4
- package/dist/cli/enrich.js +283 -79
- package/dist/cli/fix.js +20 -7
- package/dist/cli/help.js +23 -15
- package/dist/cli/market.js +180 -2
- package/dist/cli/plans.js +154 -9
- package/dist/cli/shared.d.ts +3 -1
- package/dist/cli/shared.js +2 -2
- package/dist/cli/ui.d.ts +10 -5
- package/dist/cli/ui.js +18 -5
- package/dist/cli.js +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +23 -2
- package/dist/connectors/linkedin.d.ts +2 -0
- package/dist/connectors/linkedin.js +5 -0
- package/dist/connectors/prospectSources.d.ts +23 -0
- package/dist/connectors/prospectSources.js +25 -14
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/salesforceAuth.d.ts +9 -0
- package/dist/connectors/salesforceAuth.js +47 -5
- package/dist/connectors/theirstack.d.ts +0 -19
- package/dist/connectors/theirstack.js +3 -10
- package/dist/credentials.js +36 -26
- package/dist/enrich.d.ts +44 -1
- package/dist/hostedAcquireCheckpoint.d.ts +43 -0
- package/dist/hostedAcquireCheckpoint.js +129 -0
- package/dist/hostedPatchPlan.d.ts +87 -0
- package/dist/hostedPatchPlan.js +270 -0
- package/dist/icp.js +13 -2
- package/dist/index.d.ts +7 -3
- package/dist/index.js +6 -2
- package/dist/market.d.ts +1 -1
- package/dist/market.js +7 -127
- package/dist/marketClassify.d.ts +10 -0
- package/dist/marketClassify.js +52 -1
- package/dist/marketSourcing.js +7 -45
- package/dist/mcp.js +67 -115
- package/dist/planStore.d.ts +42 -1
- package/dist/planStore.js +268 -49
- package/dist/progress.d.ts +2 -2
- package/dist/progress.js +2 -2
- package/dist/providerError.d.ts +21 -0
- package/dist/providerError.js +30 -0
- package/dist/publicHttp.d.ts +28 -0
- package/dist/publicHttp.js +143 -0
- package/dist/secureFile.d.ts +15 -0
- package/dist/secureFile.js +164 -0
- package/dist/types.d.ts +1 -1
- package/docs/api.md +53 -2
- package/docs/architecture.md +22 -2
- package/llms.txt +7 -0
- package/package.json +5 -3
- package/skills/fullstackgtm/SKILL.md +8 -2
- package/src/acquireCheckpoint.ts +186 -0
- package/src/acquireLinkedIn.ts +3 -1
- package/src/cli/audit.ts +10 -7
- package/src/cli/auth.ts +8 -4
- package/src/cli/enrich.ts +322 -78
- package/src/cli/fix.ts +28 -7
- package/src/cli/help.ts +24 -15
- package/src/cli/market.ts +181 -2
- package/src/cli/plans.ts +159 -9
- package/src/cli/shared.ts +2 -1
- package/src/cli/ui.ts +19 -9
- package/src/cli.ts +1 -1
- package/src/config.ts +39 -1
- package/src/connectors/linkedin.ts +6 -0
- package/src/connectors/prospectSources.ts +50 -15
- package/src/connectors/salesforce.ts +12 -5
- package/src/connectors/salesforceAuth.ts +46 -6
- package/src/connectors/theirstack.ts +3 -10
- package/src/credentials.ts +47 -28
- package/src/enrich.ts +47 -1
- package/src/hostedAcquireCheckpoint.ts +156 -0
- package/src/hostedPatchPlan.ts +286 -0
- package/src/icp.ts +14 -2
- package/src/index.ts +24 -0
- package/src/market.ts +7 -111
- package/src/marketClassify.ts +61 -1
- package/src/marketSourcing.ts +7 -43
- package/src/mcp.ts +93 -136
- package/src/planStore.ts +322 -57
- package/src/progress.ts +2 -2
- package/src/providerError.ts +37 -0
- package/src/publicHttp.ts +147 -0
- package/src/secureFile.ts +169 -0
- package/src/types.ts +1 -0
package/src/planStore.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { chmodSync, mkdirSync, readdirSync,
|
|
1
|
+
import { chmodSync, closeSync, mkdirSync, openSync, readdirSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.ts";
|
|
4
5
|
import { computeApprovalDigests, loadOrCreateSigningKey } from "./integrity.ts";
|
|
6
|
+
import { assertNoSymlinkComponents, readSecureRegularFile, UnsafeManagedPathError } from "./secureFile.ts";
|
|
5
7
|
import type { ApprovalStatus, PatchPlan, PatchPlanRun } from "./types.ts";
|
|
6
8
|
|
|
7
9
|
/**
|
|
@@ -23,11 +25,34 @@ export type StoredPlan = {
|
|
|
23
25
|
* file is caught instead of written. Absent on plans approved before 0.26.0.
|
|
24
26
|
*/
|
|
25
27
|
approvalDigests?: Record<string, string>;
|
|
28
|
+
/** Monotonic store revision used to make lifecycle changes explicit. */
|
|
29
|
+
revision: number;
|
|
30
|
+
/** Present only while one process owns the right to apply this plan. */
|
|
31
|
+
applyClaim?: {
|
|
32
|
+
id: string;
|
|
33
|
+
claimedAt: string;
|
|
34
|
+
revision: number;
|
|
35
|
+
hostedClaimId?: string;
|
|
36
|
+
};
|
|
37
|
+
/** Durable journal of apply ownership. An unresolved entry must never be replayed automatically. */
|
|
38
|
+
applyAttempts?: ApplyAttempt[];
|
|
26
39
|
runs: PatchPlanRun[];
|
|
27
40
|
createdAt: string;
|
|
28
41
|
updatedAt: string;
|
|
29
42
|
};
|
|
30
43
|
|
|
44
|
+
export type ApplyAttempt = {
|
|
45
|
+
id: string;
|
|
46
|
+
claimedAt: string;
|
|
47
|
+
provider: string;
|
|
48
|
+
source: "cli" | "fix" | "mcp";
|
|
49
|
+
status: "in_progress" | "preflight_aborted" | "completed" | "uncertain";
|
|
50
|
+
resolvedAt?: string;
|
|
51
|
+
/** Deliberately generic: provider errors can contain CRM data or credentials. */
|
|
52
|
+
note?: string;
|
|
53
|
+
hostedClaimId?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
31
56
|
export interface PlanStore {
|
|
32
57
|
save(plan: PatchPlan): Promise<StoredPlan>;
|
|
33
58
|
get(planId: string): Promise<StoredPlan | null>;
|
|
@@ -38,9 +63,23 @@ export interface PlanStore {
|
|
|
38
63
|
valueOverrides?: Record<string, unknown>,
|
|
39
64
|
): Promise<StoredPlan>;
|
|
40
65
|
reject(planId: string): Promise<StoredPlan>;
|
|
41
|
-
|
|
66
|
+
claimApply(planId: string, metadata?: Pick<ApplyAttempt, "provider" | "source">): Promise<{ stored: StoredPlan; claimId: string }>;
|
|
67
|
+
recordHostedClaim(planId: string, claimId: string, hostedClaimId: string): Promise<StoredPlan>;
|
|
68
|
+
recordRun(planId: string, run: PatchPlanRun, claimId: string): Promise<StoredPlan>;
|
|
69
|
+
abortApplyPreflight(planId: string, claimId: string, note: string): Promise<StoredPlan>;
|
|
70
|
+
markApplyUncertain(planId: string, claimId: string): Promise<StoredPlan>;
|
|
71
|
+
recoverApply(planId: string): Promise<StoredPlan>;
|
|
72
|
+
reconcileReplica(planId: string, remote: ReplicaPlanState): Promise<{ stored: StoredPlan; changed: boolean }>;
|
|
42
73
|
}
|
|
43
74
|
|
|
75
|
+
/** A validated lifecycle snapshot received from another plan replica. */
|
|
76
|
+
export type ReplicaPlanState = {
|
|
77
|
+
status: "approved" | "rejected" | "applied";
|
|
78
|
+
approvedOperationIds: string[];
|
|
79
|
+
valueOverrides?: Record<string, unknown>;
|
|
80
|
+
run?: PatchPlanRun;
|
|
81
|
+
};
|
|
82
|
+
|
|
44
83
|
/**
|
|
45
84
|
* Plans as JSON files in a directory (default `$FSGTM_HOME/plans`), one file
|
|
46
85
|
* per plan id. Filesystem-shaped on purpose: greppable, diffable, and any
|
|
@@ -49,6 +88,12 @@ export interface PlanStore {
|
|
|
49
88
|
export function createFilePlanStore(directory?: string): PlanStore {
|
|
50
89
|
const dir = directory ?? join(credentialsDir(), "plans");
|
|
51
90
|
|
|
91
|
+
function ensurePlanDirectory(): void {
|
|
92
|
+
assertNoSymlinkComponents(dir);
|
|
93
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
94
|
+
assertNoSymlinkComponents(dir);
|
|
95
|
+
}
|
|
96
|
+
|
|
52
97
|
function pathFor(planId: string) {
|
|
53
98
|
if (!/^[\w.-]+$/.test(planId)) throw new Error(`Invalid plan id: ${planId}`);
|
|
54
99
|
return join(dir, `${planId}.json`);
|
|
@@ -56,8 +101,10 @@ export function createFilePlanStore(directory?: string): PlanStore {
|
|
|
56
101
|
|
|
57
102
|
function read(planId: string): StoredPlan | null {
|
|
58
103
|
try {
|
|
59
|
-
|
|
60
|
-
|
|
104
|
+
assertNoSymlinkComponents(dir);
|
|
105
|
+
return JSON.parse(readSecureRegularFile(pathFor(planId))) as StoredPlan;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof UnsafeManagedPathError) throw error;
|
|
61
108
|
return null;
|
|
62
109
|
}
|
|
63
110
|
}
|
|
@@ -68,13 +115,17 @@ export function createFilePlanStore(directory?: string): PlanStore {
|
|
|
68
115
|
// otherwise an `audit --save` before any `login` would create the home
|
|
69
116
|
// directory world-readable.
|
|
70
117
|
if (!directory) ensureSecureHomeDir();
|
|
71
|
-
|
|
118
|
+
ensurePlanDirectory();
|
|
72
119
|
try {
|
|
73
120
|
chmodSync(dir, 0o700);
|
|
74
121
|
} catch {
|
|
75
122
|
// Non-POSIX filesystems ignore chmod.
|
|
76
123
|
}
|
|
77
|
-
const next = {
|
|
124
|
+
const next = {
|
|
125
|
+
...stored,
|
|
126
|
+
revision: (stored.revision ?? 0) + 1,
|
|
127
|
+
updatedAt: new Date().toISOString(),
|
|
128
|
+
};
|
|
78
129
|
writeSecureFile(pathFor(stored.plan.id), `${JSON.stringify(next, null, 2)}\n`);
|
|
79
130
|
return next;
|
|
80
131
|
}
|
|
@@ -85,17 +136,47 @@ export function createFilePlanStore(directory?: string): PlanStore {
|
|
|
85
136
|
return stored;
|
|
86
137
|
}
|
|
87
138
|
|
|
139
|
+
function withPlanLock<T>(planId: string, action: () => T): T {
|
|
140
|
+
ensurePlanDirectory();
|
|
141
|
+
const lockPath = `${pathFor(planId)}.lock`;
|
|
142
|
+
let fd: number;
|
|
143
|
+
try {
|
|
144
|
+
fd = openSync(lockPath, "wx", 0o600);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
const code = error instanceof Error && "code" in error ? String(error.code) : "unknown";
|
|
147
|
+
if (code === "EEXIST") {
|
|
148
|
+
throw new Error(`Plan ${planId} is being updated by another process; retry after it finishes.`);
|
|
149
|
+
}
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
return action();
|
|
154
|
+
} finally {
|
|
155
|
+
closeSync(fd);
|
|
156
|
+
try { unlinkSync(lockPath); } catch { /* a missing lock is already released */ }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
88
160
|
return {
|
|
89
161
|
async save(plan) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
162
|
+
return withPlanLock(plan.id, () => {
|
|
163
|
+
const existing = read(plan.id);
|
|
164
|
+
if (existing && ["approved", "applying", "applied"].includes(existing.status)) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Plan ${plan.id} is already ${existing.status}; refusing to replace its approval or apply history.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const now = new Date().toISOString();
|
|
170
|
+
return write({
|
|
171
|
+
plan,
|
|
172
|
+
status: plan.status,
|
|
173
|
+
approvedOperationIds: [],
|
|
174
|
+
valueOverrides: {},
|
|
175
|
+
revision: 0,
|
|
176
|
+
runs: [],
|
|
177
|
+
createdAt: now,
|
|
178
|
+
updatedAt: now,
|
|
179
|
+
});
|
|
99
180
|
});
|
|
100
181
|
},
|
|
101
182
|
|
|
@@ -104,6 +185,7 @@ export function createFilePlanStore(directory?: string): PlanStore {
|
|
|
104
185
|
},
|
|
105
186
|
|
|
106
187
|
async list(status) {
|
|
188
|
+
assertNoSymlinkComponents(dir);
|
|
107
189
|
let entries: string[] = [];
|
|
108
190
|
try {
|
|
109
191
|
entries = readdirSync(dir);
|
|
@@ -119,55 +201,238 @@ export function createFilePlanStore(directory?: string): PlanStore {
|
|
|
119
201
|
},
|
|
120
202
|
|
|
121
203
|
async approveOperations(planId, operationIds, valueOverrides = {}) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (stored.status === "rejected") {
|
|
127
|
-
throw new Error(`Plan ${planId} was rejected; re-run the audit for a fresh plan.`);
|
|
128
|
-
}
|
|
129
|
-
const known = new Set(stored.plan.operations.map((operation) => operation.id));
|
|
130
|
-
for (const operationId of operationIds) {
|
|
131
|
-
if (!known.has(operationId)) {
|
|
132
|
-
throw new Error(`Plan ${planId} has no operation ${operationId}.`);
|
|
204
|
+
return withPlanLock(planId, () => {
|
|
205
|
+
const stored = mustRead(planId);
|
|
206
|
+
if (stored.status === "applied") {
|
|
207
|
+
throw new Error(`Plan ${planId} has already been applied.`);
|
|
133
208
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
...stored,
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
approvalDigests
|
|
209
|
+
if (stored.status === "rejected") {
|
|
210
|
+
throw new Error(`Plan ${planId} was rejected; re-run the audit for a fresh plan.`);
|
|
211
|
+
}
|
|
212
|
+
if (stored.status === "applying" || stored.applyClaim) {
|
|
213
|
+
throw new Error(`Plan ${planId} is applying; approvals are frozen until the attempt is resolved.`);
|
|
214
|
+
}
|
|
215
|
+
const known = new Set(stored.plan.operations.map((operation) => operation.id));
|
|
216
|
+
for (const operationId of operationIds) {
|
|
217
|
+
if (!known.has(operationId)) {
|
|
218
|
+
throw new Error(`Plan ${planId} has no operation ${operationId}.`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const approvedOperationIds = Array.from(
|
|
222
|
+
new Set([...stored.approvedOperationIds, ...operationIds]),
|
|
223
|
+
);
|
|
224
|
+
const mergedOverrides = { ...stored.valueOverrides, ...valueOverrides };
|
|
225
|
+
// Bind the approval to the operation content so apply can detect a
|
|
226
|
+
// post-approval edit. Recompute over ALL approved ops (a later approve
|
|
227
|
+
// call may add overrides that change an earlier op's resolved value).
|
|
228
|
+
const approvalDigests = computeApprovalDigests(
|
|
229
|
+
stored.plan.operations,
|
|
230
|
+
approvedOperationIds,
|
|
231
|
+
mergedOverrides,
|
|
232
|
+
loadOrCreateSigningKey(),
|
|
233
|
+
);
|
|
234
|
+
return write({
|
|
235
|
+
...stored,
|
|
236
|
+
status: "approved",
|
|
237
|
+
approvedOperationIds,
|
|
238
|
+
valueOverrides: mergedOverrides,
|
|
239
|
+
approvalDigests,
|
|
240
|
+
});
|
|
154
241
|
});
|
|
155
242
|
},
|
|
156
243
|
|
|
157
244
|
async reject(planId) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
245
|
+
return withPlanLock(planId, () => {
|
|
246
|
+
const stored = mustRead(planId);
|
|
247
|
+
if (stored.status === "applied") {
|
|
248
|
+
throw new Error(`Plan ${planId} has already been applied.`);
|
|
249
|
+
}
|
|
250
|
+
if (stored.status === "applying" || stored.applyClaim) {
|
|
251
|
+
throw new Error(`Plan ${planId} is applying and cannot be rejected until the attempt is resolved.`);
|
|
252
|
+
}
|
|
253
|
+
return write({ ...stored, status: "rejected", approvedOperationIds: [], applyClaim: undefined });
|
|
254
|
+
});
|
|
163
255
|
},
|
|
164
256
|
|
|
165
|
-
async
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
257
|
+
async claimApply(planId, metadata) {
|
|
258
|
+
return withPlanLock(planId, () => {
|
|
259
|
+
const stored = mustRead(planId);
|
|
260
|
+
if (stored.status === "applying" || stored.applyClaim) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`Plan ${planId} is already applying; inspect its recorded attempts before any retry.`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
if (stored.status !== "approved") {
|
|
266
|
+
throw new Error(`Plan ${planId} is ${stored.status}; only an approved plan can be claimed for apply.`);
|
|
267
|
+
}
|
|
268
|
+
const claimId = randomUUID();
|
|
269
|
+
const claimedAt = new Date().toISOString();
|
|
270
|
+
const claimed = write({
|
|
271
|
+
...stored,
|
|
272
|
+
status: "applying",
|
|
273
|
+
applyClaim: {
|
|
274
|
+
id: claimId,
|
|
275
|
+
claimedAt,
|
|
276
|
+
revision: stored.revision ?? 0,
|
|
277
|
+
},
|
|
278
|
+
applyAttempts: [...(stored.applyAttempts ?? []), {
|
|
279
|
+
id: claimId,
|
|
280
|
+
claimedAt,
|
|
281
|
+
provider: metadata?.provider ?? "unknown",
|
|
282
|
+
source: metadata?.source ?? "cli",
|
|
283
|
+
status: "in_progress",
|
|
284
|
+
}],
|
|
285
|
+
});
|
|
286
|
+
return { stored: claimed, claimId };
|
|
287
|
+
});
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
async recordRun(planId, run, claimId) {
|
|
291
|
+
return withPlanLock(planId, () => {
|
|
292
|
+
const stored = mustRead(planId);
|
|
293
|
+
if (stored.status !== "applying" || stored.applyClaim?.id !== claimId) {
|
|
294
|
+
throw new Error(`Refusing to record run for plan ${planId}: apply claim is missing or does not match.`);
|
|
295
|
+
}
|
|
296
|
+
return write({
|
|
297
|
+
...stored,
|
|
298
|
+
status: run.status === "applied" || run.status === "partial" ? "applied" : "approved",
|
|
299
|
+
applyClaim: undefined,
|
|
300
|
+
applyAttempts: (stored.applyAttempts ?? []).map((attempt) =>
|
|
301
|
+
attempt.id === claimId
|
|
302
|
+
? { ...attempt, status: "completed" as const, resolvedAt: new Date().toISOString() }
|
|
303
|
+
: attempt),
|
|
304
|
+
runs: [...stored.runs, run],
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
async recordHostedClaim(planId, claimId, hostedClaimId) {
|
|
310
|
+
return withPlanLock(planId, () => {
|
|
311
|
+
const stored = mustRead(planId);
|
|
312
|
+
if (stored.status !== "applying" || stored.applyClaim?.id !== claimId) {
|
|
313
|
+
throw new Error(`Refusing to attach hosted claim for plan ${planId}: local apply claim does not match.`);
|
|
314
|
+
}
|
|
315
|
+
return write({
|
|
316
|
+
...stored,
|
|
317
|
+
applyClaim: { ...stored.applyClaim, hostedClaimId },
|
|
318
|
+
applyAttempts: (stored.applyAttempts ?? []).map((attempt) =>
|
|
319
|
+
attempt.id === claimId ? { ...attempt, hostedClaimId } : attempt),
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
async abortApplyPreflight(planId, claimId, note) {
|
|
325
|
+
return withPlanLock(planId, () => {
|
|
326
|
+
const stored = mustRead(planId);
|
|
327
|
+
if (stored.status !== "applying" || stored.applyClaim?.id !== claimId) {
|
|
328
|
+
throw new Error(`Refusing to abort preflight for plan ${planId}: apply claim is missing or does not match.`);
|
|
329
|
+
}
|
|
330
|
+
return write({
|
|
331
|
+
...stored,
|
|
332
|
+
status: "approved",
|
|
333
|
+
applyClaim: undefined,
|
|
334
|
+
applyAttempts: (stored.applyAttempts ?? []).map((attempt) =>
|
|
335
|
+
attempt.id === claimId
|
|
336
|
+
? { ...attempt, status: "preflight_aborted" as const, resolvedAt: new Date().toISOString(), note }
|
|
337
|
+
: attempt),
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
async markApplyUncertain(planId, claimId) {
|
|
343
|
+
return withPlanLock(planId, () => {
|
|
344
|
+
const stored = mustRead(planId);
|
|
345
|
+
if (stored.status !== "applying" || stored.applyClaim?.id !== claimId) return stored;
|
|
346
|
+
return write({
|
|
347
|
+
...stored,
|
|
348
|
+
applyAttempts: (stored.applyAttempts ?? []).map((attempt) =>
|
|
349
|
+
attempt.id === claimId
|
|
350
|
+
? { ...attempt, status: "uncertain" as const, note: "Apply exited without a completed run; some provider writes may have landed." }
|
|
351
|
+
: attempt),
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
async recoverApply(planId) {
|
|
357
|
+
return withPlanLock(planId, () => {
|
|
358
|
+
const stored = mustRead(planId);
|
|
359
|
+
if (stored.status !== "applying" || !stored.applyClaim) {
|
|
360
|
+
throw new Error(`Plan ${planId} has no unresolved apply attempt.`);
|
|
361
|
+
}
|
|
362
|
+
// Recovery is deliberately privilege-decaying: discard the old approval.
|
|
363
|
+
// An operator must inspect the provider, then explicitly approve a fresh attempt.
|
|
364
|
+
return write({
|
|
365
|
+
...stored,
|
|
366
|
+
status: "needs_approval",
|
|
367
|
+
applyClaim: undefined,
|
|
368
|
+
approvedOperationIds: [],
|
|
369
|
+
valueOverrides: {},
|
|
370
|
+
approvalDigests: undefined,
|
|
371
|
+
applyAttempts: (stored.applyAttempts ?? []).map((attempt) =>
|
|
372
|
+
attempt.id === stored.applyClaim!.id
|
|
373
|
+
? { ...attempt, status: "uncertain" as const, resolvedAt: new Date().toISOString(), note: "Operator acknowledged uncertain provider state; approval cleared before any retry." }
|
|
374
|
+
: attempt),
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
async reconcileReplica(planId, remote) {
|
|
380
|
+
return withPlanLock(planId, () => {
|
|
381
|
+
const stored = mustRead(planId);
|
|
382
|
+
if (stored.status === "applying" || stored.applyClaim) {
|
|
383
|
+
throw new Error(`Plan ${planId} is applying locally; replica reconciliation will retry after it finishes.`);
|
|
384
|
+
}
|
|
385
|
+
const known = new Set(stored.plan.operations.map((operation) => operation.id));
|
|
386
|
+
const approvedOperationIds = Array.from(new Set(remote.approvedOperationIds));
|
|
387
|
+
if (approvedOperationIds.some((id) => !known.has(id))) {
|
|
388
|
+
throw new Error(`Replica state for ${planId} contains an unknown operation.`);
|
|
389
|
+
}
|
|
390
|
+
// Terminal receipts are monotonic. A delayed approval or rejection can
|
|
391
|
+
// never roll an already-applied local replica backwards.
|
|
392
|
+
if (stored.status === "applied" && remote.status !== "applied") {
|
|
393
|
+
return { stored, changed: false };
|
|
394
|
+
}
|
|
395
|
+
if (remote.status === "rejected") {
|
|
396
|
+
if (stored.status === "rejected") return { stored, changed: false };
|
|
397
|
+
return { stored: write({
|
|
398
|
+
...stored, status: "rejected", approvedOperationIds: [], valueOverrides: {},
|
|
399
|
+
approvalDigests: undefined, applyClaim: undefined,
|
|
400
|
+
}), changed: true };
|
|
401
|
+
}
|
|
402
|
+
if (approvedOperationIds.length === 0) {
|
|
403
|
+
throw new Error(`Replica ${remote.status} state for ${planId} has no approved operations.`);
|
|
404
|
+
}
|
|
405
|
+
const valueOverrides = remote.valueOverrides ?? {};
|
|
406
|
+
const approvalDigests = computeApprovalDigests(
|
|
407
|
+
stored.plan.operations, approvedOperationIds, valueOverrides, loadOrCreateSigningKey(),
|
|
408
|
+
);
|
|
409
|
+
if (remote.status === "approved") {
|
|
410
|
+
const unchanged = stored.status === "approved"
|
|
411
|
+
&& JSON.stringify(stored.approvedOperationIds) === JSON.stringify(approvedOperationIds)
|
|
412
|
+
&& JSON.stringify(stored.valueOverrides) === JSON.stringify(valueOverrides);
|
|
413
|
+
if (unchanged) return { stored, changed: false };
|
|
414
|
+
return { stored: write({
|
|
415
|
+
...stored, status: "approved", approvedOperationIds, valueOverrides, approvalDigests,
|
|
416
|
+
}), changed: true };
|
|
417
|
+
}
|
|
418
|
+
if (!remote.run || remote.run.planId !== planId) {
|
|
419
|
+
throw new Error(`Replica applied state for ${planId} is missing a matching execution receipt.`);
|
|
420
|
+
}
|
|
421
|
+
if (remote.run.results.some((result) => !known.has(result.operationId))) {
|
|
422
|
+
throw new Error(`Replica execution receipt for ${planId} contains an unknown operation.`);
|
|
423
|
+
}
|
|
424
|
+
const runKey = (run: PatchPlanRun) => `${run.startedAt}\0${run.finishedAt}\0${run.provider}`;
|
|
425
|
+
const alreadyRecorded = stored.runs.some((run) => runKey(run) === runKey(remote.run!));
|
|
426
|
+
if (stored.status === "applied" && alreadyRecorded) return { stored, changed: false };
|
|
427
|
+
return { stored: write({
|
|
428
|
+
...stored,
|
|
429
|
+
status: "applied",
|
|
430
|
+
approvedOperationIds,
|
|
431
|
+
valueOverrides,
|
|
432
|
+
approvalDigests,
|
|
433
|
+
applyClaim: undefined,
|
|
434
|
+
runs: alreadyRecorded ? stored.runs : [...stored.runs, remote.run],
|
|
435
|
+
}), changed: true };
|
|
171
436
|
});
|
|
172
437
|
},
|
|
173
438
|
};
|
package/src/progress.ts
CHANGED
|
@@ -186,8 +186,8 @@ export const RUNS_REPLAY_STAGES = ["runs", "health"] as const;
|
|
|
186
186
|
|
|
187
187
|
export const APPLY_STAGES = ["preflight", "operations", "results"] as const;
|
|
188
188
|
|
|
189
|
-
/** `enrich acquire` —
|
|
190
|
-
export const ACQUIRE_STAGES = ["
|
|
189
|
+
/** `enrich acquire` — discovery through a governed create plan. */
|
|
190
|
+
export const ACQUIRE_STAGES = ["discovery", "resolution", "plan"] as const;
|
|
191
191
|
|
|
192
192
|
export const MARKET_CAPTURE_STAGES = ["sources", "capture", "classify", "persist"] as const;
|
|
193
193
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A persistence-safe provider failure. Third-party response bodies are
|
|
3
|
+
* deliberately excluded: they can reflect credentials, filters, or PII and
|
|
4
|
+
* errors are rendered in terminals, MCP responses, and scheduled-run files.
|
|
5
|
+
*/
|
|
6
|
+
export class ProviderHttpError extends Error {
|
|
7
|
+
readonly code = "PROVIDER_HTTP_ERROR";
|
|
8
|
+
readonly provider: string;
|
|
9
|
+
readonly operation: string;
|
|
10
|
+
readonly status: number;
|
|
11
|
+
readonly retryable: boolean;
|
|
12
|
+
|
|
13
|
+
constructor(
|
|
14
|
+
provider: string,
|
|
15
|
+
operation: string,
|
|
16
|
+
status: number,
|
|
17
|
+
retryable = status === 429 || status >= 500,
|
|
18
|
+
) {
|
|
19
|
+
super(`${provider} ${operation} failed: HTTP ${status}.`);
|
|
20
|
+
this.name = "ProviderHttpError";
|
|
21
|
+
this.provider = provider;
|
|
22
|
+
this.operation = operation;
|
|
23
|
+
this.status = status;
|
|
24
|
+
this.retryable = retryable;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
toJSON() {
|
|
28
|
+
return {
|
|
29
|
+
code: this.code,
|
|
30
|
+
provider: this.provider,
|
|
31
|
+
operation: this.operation,
|
|
32
|
+
status: this.status,
|
|
33
|
+
retryable: this.retryable,
|
|
34
|
+
message: this.message,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
|
+
import { request as httpRequest, type RequestOptions } from "node:http";
|
|
3
|
+
import { request as httpsRequest } from "node:https";
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_PUBLIC_HTTP_MAX_BYTES = 5_000_000;
|
|
7
|
+
|
|
8
|
+
export type PublicAddress = { address: string; family: number };
|
|
9
|
+
export type PublicLookup = (hostname: string) => Promise<PublicAddress[]>;
|
|
10
|
+
|
|
11
|
+
function ipv4IsPrivate(ip: string): boolean {
|
|
12
|
+
const parts = ip.split(".").map(Number);
|
|
13
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
|
|
14
|
+
const [a, b, c] = parts;
|
|
15
|
+
return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) ||
|
|
16
|
+
(a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) ||
|
|
17
|
+
(a === 192 && b === 0 && (c === 0 || c === 2)) || (a === 192 && b === 168) ||
|
|
18
|
+
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
|
|
19
|
+
(a === 203 && b === 0 && c === 113) || a >= 224;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function embeddedIpv4IsPrivate(high: string, low: string): boolean {
|
|
23
|
+
const hi = Number.parseInt(high, 16);
|
|
24
|
+
const lo = Number.parseInt(low, 16);
|
|
25
|
+
if (!Number.isInteger(hi) || !Number.isInteger(lo) || hi > 0xffff || lo > 0xffff) return true;
|
|
26
|
+
return ipv4IsPrivate(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ipIsPrivate(ip: string): boolean {
|
|
30
|
+
const family = isIP(ip);
|
|
31
|
+
if (family === 4) return ipv4IsPrivate(ip);
|
|
32
|
+
if (family !== 6) return true;
|
|
33
|
+
const lower = ip.toLowerCase();
|
|
34
|
+
if (lower === "::" || lower === "::1") return true;
|
|
35
|
+
const embedded = lower.match(/^::(?:ffff:)?([0-9a-f]+):([0-9a-f]+)$/);
|
|
36
|
+
if (embedded) return embeddedIpv4IsPrivate(embedded[1], embedded[2]);
|
|
37
|
+
const embeddedDotted = lower.match(/^::(?:ffff:)?(.+\..+)$/);
|
|
38
|
+
if (embeddedDotted) return ipv4IsPrivate(embeddedDotted[1]);
|
|
39
|
+
const nat64 = lower.match(/^64:ff9b::([0-9a-f]+):([0-9a-f]+)$/);
|
|
40
|
+
if (nat64) return embeddedIpv4IsPrivate(nat64[1], nat64[2]);
|
|
41
|
+
const sixToFour = lower.match(/^2002:([0-9a-f]+):([0-9a-f]+):/);
|
|
42
|
+
if (sixToFour) return embeddedIpv4IsPrivate(sixToFour[1], sixToFour[2]);
|
|
43
|
+
return /^(?:fc|fd)/.test(lower) || /^(?:fe8|fe9|fea|feb)/.test(lower) ||
|
|
44
|
+
lower.startsWith("ff") || lower.startsWith("100:") ||
|
|
45
|
+
lower.startsWith("2001:db8:") || lower.startsWith("2001:0:") ||
|
|
46
|
+
lower.startsWith("2001:10:") || lower.startsWith("3fff:") ||
|
|
47
|
+
lower.startsWith("64:ff9b:1:");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const systemLookup: PublicLookup = async (hostname) =>
|
|
51
|
+
(await dnsLookup(hostname, { all: true, verbatim: true })).map(({ address, family }) => ({ address, family }));
|
|
52
|
+
|
|
53
|
+
/** Resolve once and reject the entire hostname if any answer is non-public. */
|
|
54
|
+
export async function resolvePublicAddresses(hostname: string, lookup: PublicLookup = systemLookup): Promise<PublicAddress[]> {
|
|
55
|
+
const literal = hostname.replace(/^\[|\]$/g, "");
|
|
56
|
+
const family = isIP(literal);
|
|
57
|
+
const addresses = family ? [{ address: literal, family }] : await lookup(literal);
|
|
58
|
+
if (addresses.length === 0) throw new Error(`public HTTP: ${literal} did not resolve to an address.`);
|
|
59
|
+
for (const { address } of addresses) {
|
|
60
|
+
if (ipIsPrivate(address)) throw new Error(`public HTTP refuses ${literal} — it resolves to private/internal address ${address} (SSRF guard).`);
|
|
61
|
+
}
|
|
62
|
+
return addresses;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function assertPublicUrl(rawUrl: string, lookup: PublicLookup = systemLookup): Promise<URL> {
|
|
66
|
+
let url: URL;
|
|
67
|
+
try { url = new URL(rawUrl); } catch { throw new Error(`market capture: "${rawUrl}" is not a valid URL.`); }
|
|
68
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
69
|
+
throw new Error(`market capture refuses ${url.protocol} URLs (only http/https): ${rawUrl}`);
|
|
70
|
+
}
|
|
71
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
72
|
+
if (isIP(host) && ipIsPrivate(host)) {
|
|
73
|
+
throw new Error(`market capture refuses private/loopback address ${host} (SSRF guard).`);
|
|
74
|
+
}
|
|
75
|
+
const addresses = await resolvePublicAddresses(host, lookup);
|
|
76
|
+
// Keep the resolved set available to the request path without changing the public API.
|
|
77
|
+
Object.defineProperty(url, "__publicAddresses", { value: addresses });
|
|
78
|
+
return url;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type PublicHttpResult = { status: number; headers: Headers; body: Uint8Array; finalUrl: string };
|
|
82
|
+
type HopResult = Omit<PublicHttpResult, "finalUrl">;
|
|
83
|
+
export type PublicRequestHop = (url: URL, addresses: PublicAddress[], headers: Record<string, string>, timeoutMs: number, maxBytes: number) => Promise<HopResult>;
|
|
84
|
+
|
|
85
|
+
const requestHop: PublicRequestHop = (url, addresses, headers, timeoutMs, maxBytes) => new Promise((resolve, reject) => {
|
|
86
|
+
let cursor = 0;
|
|
87
|
+
const options: RequestOptions = {
|
|
88
|
+
protocol: url.protocol, hostname: url.hostname, port: url.port || undefined,
|
|
89
|
+
path: `${url.pathname}${url.search}`, method: "GET", headers,
|
|
90
|
+
lookup: (_hostname, options, callback) => {
|
|
91
|
+
const requestedFamily = typeof options === "number" ? options : options?.family;
|
|
92
|
+
const eligible = requestedFamily ? addresses.filter((a) => a.family === requestedFamily) : addresses;
|
|
93
|
+
const selected = eligible[cursor++ % eligible.length];
|
|
94
|
+
if (!selected) return callback(new Error("public HTTP: no validated address matches the requested family"), "", 0);
|
|
95
|
+
callback(null, selected.address, selected.family);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
const req = (url.protocol === "https:" ? httpsRequest : httpRequest)(options, (res) => {
|
|
99
|
+
if (maxBytes === 0) {
|
|
100
|
+
resolve({ status: res.statusCode ?? 0, headers: new Headers(res.headers as Record<string, string>), body: new Uint8Array() });
|
|
101
|
+
res.destroy();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const chunks: Buffer[] = [];
|
|
105
|
+
let total = 0;
|
|
106
|
+
res.on("data", (chunk: Buffer) => {
|
|
107
|
+
total += chunk.length;
|
|
108
|
+
if (total > maxBytes) {
|
|
109
|
+
req.destroy(new Error(`public HTTP response exceeds ${maxBytes} bytes`));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
chunks.push(chunk);
|
|
113
|
+
});
|
|
114
|
+
res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: new Headers(res.headers as Record<string, string>), body: Buffer.concat(chunks) }));
|
|
115
|
+
});
|
|
116
|
+
req.setTimeout(timeoutMs, () => req.destroy(new Error(`public HTTP request timed out after ${timeoutMs}ms`)));
|
|
117
|
+
req.on("error", reject);
|
|
118
|
+
req.end();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const SENSITIVE_HEADERS = new Set(["authorization", "cookie", "proxy-authorization"]);
|
|
122
|
+
|
|
123
|
+
/** Public-only GET with DNS pinning, bounded bodies, and per-hop redirect validation. */
|
|
124
|
+
export async function publicHttpGet(rawUrl: string, options: {
|
|
125
|
+
headers?: Record<string, string>; timeoutMs?: number; maxBytes?: number; maxRedirects?: number;
|
|
126
|
+
lookup?: PublicLookup; requestHop?: PublicRequestHop;
|
|
127
|
+
} = {}): Promise<PublicHttpResult> {
|
|
128
|
+
let current = rawUrl;
|
|
129
|
+
let headers = { ...(options.headers ?? {}) };
|
|
130
|
+
const maxRedirects = options.maxRedirects ?? 5;
|
|
131
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
132
|
+
const url = await assertPublicUrl(current, options.lookup);
|
|
133
|
+
const addresses = (url as URL & { __publicAddresses: PublicAddress[] }).__publicAddresses;
|
|
134
|
+
const result = await (options.requestHop ?? requestHop)(url, addresses, headers, options.timeoutMs ?? 15_000, options.maxBytes ?? DEFAULT_PUBLIC_HTTP_MAX_BYTES);
|
|
135
|
+
const location = result.headers.get("location");
|
|
136
|
+
if (result.status >= 300 && result.status < 400 && location) {
|
|
137
|
+
const next = new URL(location, url);
|
|
138
|
+
if (next.origin !== url.origin) {
|
|
139
|
+
headers = Object.fromEntries(Object.entries(headers).filter(([key]) => !SENSITIVE_HEADERS.has(key.toLowerCase())));
|
|
140
|
+
}
|
|
141
|
+
current = next.toString();
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
return { ...result, finalUrl: current };
|
|
145
|
+
}
|
|
146
|
+
throw new Error(`public HTTP: too many redirects (>${maxRedirects}) for ${rawUrl}`);
|
|
147
|
+
}
|