agentlinkops 0.6.9 → 0.6.11
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +18 -0
- package/README.md +44 -12
- package/cli/admission.js +9 -1
- package/cli/agent-setup.js +189 -20
- package/cli/catalog.js +7938 -1357
- package/cli/client.js +89 -23
- package/cli/cloud-error.js +21 -0
- package/cli/commands.js +15 -3
- package/cli/connection.js +74 -12
- package/cli/doctor.js +24 -9
- package/cli/gsc-browser.js +19 -6
- package/cli/import.js +4 -1
- package/cli/ledger.js +4 -7
- package/cli/lifecycle.js +5 -2
- package/cli/local-result.js +4 -4
- package/cli/main.js +50 -34
- package/cli/mcp-client.js +198 -0
- package/cli/native-lock.js +22 -0
- package/cli/oauth-credentials.js +196 -0
- package/cli/oauth-flow.js +259 -0
- package/cli/setup-plan.js +5 -5
- package/cli/status.js +14 -2
- package/cli/sync-lock.js +105 -0
- package/cli/sync.js +14 -6
- package/package.json +6 -3
- package/references/cli.md +22 -0
- package/references/local-crm-draft-handoff.md +76 -0
- package/references/portable-ledger-handoff.md +50 -0
- package/references/recipes/catalog.json +9 -9
- package/references/recipes/command-notes.md +17 -1
- package/references/recipes/placement-reconciliation.md +19 -5
- package/references/recipes/qualified-campaign-handoff.md +16 -4
- package/references/recipes/site-context-brief.md +2 -2
- package/references/recipes/sourced-linkable-asset.md +8 -8
- package/scripts/agentlinkops.py +30 -5
- package/scripts/outreach-crm-draft.py +121 -0
- package/scripts/portable-handoff.py +184 -0
- package/shared/lifecycle-contract.js +2 -2
- package/shared/response-contract.js +7 -1
- package/skills/agentlinkops-assets/SKILL.md +2 -2
- package/skills/agentlinkops-campaigns/SKILL.md +3 -3
- package/skills/agentlinkops-connect/SKILL.md +29 -6
- package/skills/agentlinkops-connect/references/agentlinkops.md +34 -13
- package/skills/agentlinkops-crm/SKILL.md +18 -4
- package/src/citations/adapter.js +3 -2
- package/src/citations/browser/engine.js +23 -7
- package/src/citations/login.js +42 -5
- package/src/discovery/contract.js +1 -1
- package/src/response-shaping.js +58 -0
- package/src/tool-views.js +3 -1
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import {createServer} from 'node:http';
|
|
2
|
+
import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
|
|
3
|
+
import {lookup} from 'node:dns/promises';
|
|
4
|
+
import ipaddr from 'ipaddr.js';
|
|
5
|
+
import {validatePublicUrl} from '../src/verifier/url.js';
|
|
6
|
+
|
|
7
|
+
const plans = new WeakSet();
|
|
8
|
+
const resolvers = new WeakMap();
|
|
9
|
+
const authorizations = new WeakMap();
|
|
10
|
+
const LIMIT = 1024 * 1024;
|
|
11
|
+
const fail = code => { throw Object.assign(new Error(code), {code}); };
|
|
12
|
+
const check = (value, code) => { if (!value) fail(code); };
|
|
13
|
+
const same = (a, b) => typeof a === 'string' && typeof b === 'string'
|
|
14
|
+
&& Buffer.byteLength(a) === Buffer.byteLength(b) && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
15
|
+
const secret = value => typeof value === 'string' && value.length > 0 && value.length <= 65536 && !/[\s\u0000-\u001f\u007f]/.test(value);
|
|
16
|
+
const normalizeScopes = value => {
|
|
17
|
+
check(Array.isArray(value) && value.length > 0 && value.length <= 100
|
|
18
|
+
&& value.every(s => typeof s === 'string' && /^[\x21\x23-\x5b\x5d-\x7e]{1,200}$/.test(s))
|
|
19
|
+
&& new Set(value).size === value.length, 'INVALID_OAUTH_SCOPES');
|
|
20
|
+
return [...value].sort();
|
|
21
|
+
};
|
|
22
|
+
const scopeString = value => {
|
|
23
|
+
check(typeof value === 'string' && value.length <= 20000 && value.trim() === value
|
|
24
|
+
&& !value.includes(' '), 'TOKEN_SCOPE_MISMATCH');
|
|
25
|
+
try { return normalizeScopes(value.split(' ')); } catch { fail('TOKEN_SCOPE_MISMATCH'); }
|
|
26
|
+
};
|
|
27
|
+
const sameScopes = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
28
|
+
function publicHttps(value, code) {
|
|
29
|
+
check(typeof value === 'string' && validatePublicUrl(value).valid, code);
|
|
30
|
+
let url; try { url = new URL(value); } catch { fail(code); }
|
|
31
|
+
check(url.protocol === 'https:' && !url.hash && !url.username && !url.password, code);
|
|
32
|
+
return url;
|
|
33
|
+
}
|
|
34
|
+
function originOf(value) {
|
|
35
|
+
const url = publicHttps(value, 'CANONICAL_HTTPS_ORIGIN_REQUIRED');
|
|
36
|
+
check(value === url.origin, 'CANONICAL_HTTPS_ORIGIN_REQUIRED');
|
|
37
|
+
return url.origin;
|
|
38
|
+
}
|
|
39
|
+
function endpoint(value, issuer) {
|
|
40
|
+
const url = publicHttps(value, 'ISSUER_ENDPOINT_INVALID');
|
|
41
|
+
check(url.origin === issuer && value === url.href && !value.includes('?') && !value.includes('#'), 'ISSUER_ENDPOINT_INVALID');
|
|
42
|
+
return url.href;
|
|
43
|
+
}
|
|
44
|
+
function requirePlan(plan) {
|
|
45
|
+
check(plan && plans.has(plan), 'OAUTH_PLAN_REQUIRED');
|
|
46
|
+
return plan;
|
|
47
|
+
}
|
|
48
|
+
function requested(plan, scopes) {
|
|
49
|
+
const value = normalizeScopes(scopes);
|
|
50
|
+
check(value.includes('offline_access') && value.every(s => plan.scopes.includes(s)), 'INVALID_OAUTH_SCOPES');
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const defaultResolver = hostname => lookup(hostname, {all: true});
|
|
55
|
+
async function publicDestination(url, resolveHost) {
|
|
56
|
+
check(typeof resolveHost === 'function', 'OAUTH_DESTINATION_REFUSED');
|
|
57
|
+
let timer;
|
|
58
|
+
try {
|
|
59
|
+
// This is DNS preflight, not address pinning or a rebinding-proof transport.
|
|
60
|
+
const addresses = await Promise.race([Promise.resolve().then(() => resolveHost(new URL(url).hostname)),
|
|
61
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error()), 20000); })]);
|
|
62
|
+
check(Array.isArray(addresses) && addresses.length > 0 && addresses.length <= 100
|
|
63
|
+
&& addresses.every(value => {
|
|
64
|
+
try { return ipaddr.process(typeof value === 'string' ? value : value?.address).range() === 'unicast'; }
|
|
65
|
+
catch { return false; }
|
|
66
|
+
}), 'OAUTH_DESTINATION_REFUSED');
|
|
67
|
+
} catch { fail('OAUTH_DESTINATION_REFUSED'); }
|
|
68
|
+
finally { clearTimeout(timer); }
|
|
69
|
+
}
|
|
70
|
+
async function jsonRequest(fetchImpl, url, init = {}, resolveHost = defaultResolver) {
|
|
71
|
+
await publicDestination(url, resolveHost);
|
|
72
|
+
let response;
|
|
73
|
+
try { response = await fetchImpl(url, {...init, redirect: 'error', signal: AbortSignal.timeout(20000)}); }
|
|
74
|
+
catch { fail('OAUTH_REQUEST_FAILED'); }
|
|
75
|
+
check(response && !response.redirected && !(response.status >= 300 && response.status < 400)
|
|
76
|
+
&& (!response.url || response.url === url), 'OAUTH_REDIRECT_REFUSED');
|
|
77
|
+
check(response.headers?.get('content-type')?.split(';')[0].trim().toLowerCase() === 'application/json', 'JSON_RESPONSE_REQUIRED');
|
|
78
|
+
const length = response.headers.get('content-length');
|
|
79
|
+
if (length !== null) check(/^\d+$/.test(length) && Number(length) <= LIMIT, 'RESPONSE_TOO_LARGE');
|
|
80
|
+
check(response.body?.getReader, 'JSON_RESPONSE_REQUIRED');
|
|
81
|
+
const reader = response.body.getReader(), chunks = [];
|
|
82
|
+
let bytes = 0;
|
|
83
|
+
try {
|
|
84
|
+
while (true) {
|
|
85
|
+
const {done, value} = await reader.read();
|
|
86
|
+
if (done) break;
|
|
87
|
+
bytes += value.byteLength;
|
|
88
|
+
if (bytes > LIMIT) { await reader.cancel().catch(() => {}); fail('RESPONSE_TOO_LARGE'); }
|
|
89
|
+
chunks.push(Buffer.from(value));
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error?.code === 'RESPONSE_TOO_LARGE') throw error;
|
|
93
|
+
fail('OAUTH_RESPONSE_FAILED');
|
|
94
|
+
} finally { reader.releaseLock(); }
|
|
95
|
+
let data;
|
|
96
|
+
try { data = JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(Buffer.concat(chunks, bytes))); }
|
|
97
|
+
catch { fail('JSON_RESPONSE_REQUIRED'); }
|
|
98
|
+
check(data && typeof data === 'object' && !Array.isArray(data), 'JSON_RESPONSE_REQUIRED');
|
|
99
|
+
return {response, data};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function discoverOAuth({origin, issuer, clientId, scopes, fetchImpl = fetch, resolveHost = defaultResolver}) {
|
|
103
|
+
originOf(origin); originOf(issuer);
|
|
104
|
+
check(typeof clientId === 'string' && clientId.length > 0 && clientId.length <= 2048
|
|
105
|
+
&& !/[\s\u0000-\u001f\u007f]/.test(clientId), 'EXISTING_PUBLIC_CLIENT_REQUIRED');
|
|
106
|
+
const declared = normalizeScopes(scopes);
|
|
107
|
+
check(declared.includes('offline_access'), 'INVALID_OAUTH_SCOPES');
|
|
108
|
+
const resource = `${origin}/mcp`;
|
|
109
|
+
const protectedResource = await jsonRequest(fetchImpl, `${origin}/.well-known/oauth-protected-resource/mcp`, {}, resolveHost);
|
|
110
|
+
const p = protectedResource.data;
|
|
111
|
+
check(protectedResource.response.ok && p.resource === resource
|
|
112
|
+
&& Array.isArray(p.authorization_servers) && p.authorization_servers.length === 1
|
|
113
|
+
&& p.authorization_servers[0] === issuer, 'RESOURCE_DISCOVERY_MISMATCH');
|
|
114
|
+
check(Array.isArray(p.scopes_supported) && declared.filter(s => s !== 'offline_access')
|
|
115
|
+
.every(s => p.scopes_supported.includes(s)), 'RESOURCE_SCOPES_MISSING');
|
|
116
|
+
const discovery = await jsonRequest(fetchImpl, `${issuer}/.well-known/oauth-authorization-server`, {}, resolveHost);
|
|
117
|
+
const m = discovery.data;
|
|
118
|
+
check(discovery.response.ok && m.issuer === issuer, 'ISSUER_DISCOVERY_MISMATCH');
|
|
119
|
+
check(Array.isArray(m.code_challenge_methods_supported) && m.code_challenge_methods_supported.includes('S256'), 'PKCE_DISCOVERY_FAILED');
|
|
120
|
+
check(Array.isArray(m.grant_types_supported) && ['authorization_code', 'refresh_token']
|
|
121
|
+
.every(g => m.grant_types_supported.includes(g)), 'GRANT_DISCOVERY_FAILED');
|
|
122
|
+
if (m.response_types_supported !== undefined)
|
|
123
|
+
check(Array.isArray(m.response_types_supported) && m.response_types_supported.includes('code'), 'GRANT_DISCOVERY_FAILED');
|
|
124
|
+
if (m.token_endpoint_auth_methods_supported !== undefined)
|
|
125
|
+
check(Array.isArray(m.token_endpoint_auth_methods_supported) && m.token_endpoint_auth_methods_supported.includes('none'), 'EXISTING_PUBLIC_CLIENT_REQUIRED');
|
|
126
|
+
if (m.scopes_supported !== undefined)
|
|
127
|
+
check(Array.isArray(m.scopes_supported) && declared.every(s => m.scopes_supported.includes(s)), 'RESOURCE_SCOPES_MISSING');
|
|
128
|
+
const plan = Object.freeze({origin, issuer, clientId, resource, scopes: Object.freeze(declared),
|
|
129
|
+
authorizationEndpoint: endpoint(m.authorization_endpoint, issuer), tokenEndpoint: endpoint(m.token_endpoint, issuer),
|
|
130
|
+
...(m.revocation_endpoint === undefined ? {} : {revocationEndpoint: endpoint(m.revocation_endpoint, issuer)}),
|
|
131
|
+
requireIssuerResponse: m.authorization_response_iss_parameter_supported === true});
|
|
132
|
+
plans.add(plan);
|
|
133
|
+
resolvers.set(plan, resolveHost);
|
|
134
|
+
return plan;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function receiveOAuthConsent({plan, scopes, timeoutMs = 600000, onAuthorization}) {
|
|
138
|
+
requirePlan(plan);
|
|
139
|
+
const selected = requested(plan, scopes);
|
|
140
|
+
check(Number.isInteger(timeoutMs) && timeoutMs >= 1 && timeoutMs <= 600000 && typeof onAuthorization === 'function', 'INVALID_OAUTH_CONSENT');
|
|
141
|
+
await publicDestination(plan.authorizationEndpoint, resolvers.get(plan));
|
|
142
|
+
const verifier = randomBytes(64).toString('base64url'), state = randomBytes(32).toString('base64url');
|
|
143
|
+
let finish, rejectCallback, rejectTimeout, used = false, timer;
|
|
144
|
+
const received = new Promise((resolve, reject) => { finish = resolve; rejectCallback = reject; });
|
|
145
|
+
received.catch(() => {});
|
|
146
|
+
const timedOut = new Promise((_, reject) => { rejectTimeout = reject; });
|
|
147
|
+
timedOut.catch(() => {});
|
|
148
|
+
const headers = {'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'Content-Type': 'text/plain',
|
|
149
|
+
'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'"};
|
|
150
|
+
const server = createServer({maxHeaderSize: 16384}, (request, response) => {
|
|
151
|
+
if (typeof request.url !== 'string' || request.url.length > 8192) {
|
|
152
|
+
response.writeHead(400, headers); response.end('Invalid authorization response.'); return;
|
|
153
|
+
}
|
|
154
|
+
let url; try { url = new URL(request.url, 'http://127.0.0.1'); }
|
|
155
|
+
catch { response.writeHead(400, headers); response.end('Invalid authorization response.'); return; }
|
|
156
|
+
if (request.method === 'GET' && url.pathname === '/oauth/complete') {
|
|
157
|
+
response.writeHead(200, headers); response.end('Authorization received. You can close this tab.'); return;
|
|
158
|
+
}
|
|
159
|
+
if (request.method !== 'GET' || url.pathname !== '/oauth/callback' || request.socket.remoteAddress !== '127.0.0.1'
|
|
160
|
+
|| !request.url.startsWith('/') || request.url.startsWith('//')
|
|
161
|
+
|| request.headers.host !== `127.0.0.1:${server.address().port}` || used) {
|
|
162
|
+
response.writeHead(404, headers); response.end('Not found.'); return;
|
|
163
|
+
}
|
|
164
|
+
const params = url.searchParams;
|
|
165
|
+
const duplicate = ['code', 'state', 'iss', 'error'].some(k => params.getAll(k).length > 1);
|
|
166
|
+
if (duplicate || !same(params.get('state'), state) || (params.has('iss') && params.get('iss') !== plan.issuer)
|
|
167
|
+
|| (plan.requireIssuerResponse && !params.has('iss')) || (params.has('code') && params.has('error'))) {
|
|
168
|
+
response.writeHead(400, headers); response.end('Invalid authorization response.'); return;
|
|
169
|
+
}
|
|
170
|
+
used = true;
|
|
171
|
+
response.writeHead(303, {...headers, Location: '/oauth/complete'}); response.end();
|
|
172
|
+
if (params.has('error')) rejectCallback(Object.assign(new Error('CONSENT_DENIED'), {code: 'CONSENT_DENIED'}));
|
|
173
|
+
else if (!secret(params.get('code'))) rejectCallback(Object.assign(new Error('AUTHORIZATION_CODE_MISSING'), {code: 'AUTHORIZATION_CODE_MISSING'}));
|
|
174
|
+
else finish(params.get('code'));
|
|
175
|
+
});
|
|
176
|
+
server.requestTimeout = 20000; server.headersTimeout = 10000;
|
|
177
|
+
try {
|
|
178
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
179
|
+
const redirectUri = `http://127.0.0.1:${server.address().port}/oauth/callback`;
|
|
180
|
+
const url = new URL(plan.authorizationEndpoint);
|
|
181
|
+
for (const [key, value] of Object.entries({response_type: 'code', client_id: plan.clientId, redirect_uri: redirectUri,
|
|
182
|
+
scope: selected.join(' '), resource: plan.resource, state, code_challenge: createHash('sha256').update(verifier).digest('base64url'),
|
|
183
|
+
code_challenge_method: 'S256'})) url.searchParams.set(key, value);
|
|
184
|
+
timer = setTimeout(() => rejectTimeout(Object.assign(new Error('CONSENT_PENDING'), {code: 'CONSENT_PENDING'})), timeoutMs);
|
|
185
|
+
// Race the browser hook too: a stalled opener must not retain the listener forever.
|
|
186
|
+
await Promise.race([Promise.resolve().then(() => onAuthorization({authorizationUrl: url.href, redirectUri,
|
|
187
|
+
clientId: plan.clientId, scopes: [...selected]})), timedOut]);
|
|
188
|
+
const authorization = Object.freeze({code: await Promise.race([received, timedOut]), verifier, redirectUri});
|
|
189
|
+
authorizations.set(authorization, {plan, scopes: selected, used: false});
|
|
190
|
+
return authorization;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (['CONSENT_DENIED', 'AUTHORIZATION_CODE_MISSING', 'CONSENT_PENDING'].includes(error?.code)) fail(error.code);
|
|
193
|
+
fail('OAUTH_CONSENT_FAILED');
|
|
194
|
+
} finally {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
server.closeAllConnections();
|
|
197
|
+
await new Promise(done => server.close(done));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function retainTokens(plan, data, selected, now, previous) {
|
|
202
|
+
const at = typeof now === 'function' ? now() : now;
|
|
203
|
+
check(Number.isSafeInteger(at) && at >= 0, 'INVALID_OAUTH_CLOCK');
|
|
204
|
+
check(secret(data.access_token) && typeof data.token_type === 'string' && data.token_type.toLowerCase() === 'bearer'
|
|
205
|
+
&& Number.isSafeInteger(data.expires_in) && data.expires_in > 0 && data.expires_in <= 31536000, 'TOKEN_CONTRACT_MISMATCH');
|
|
206
|
+
check((data.resource === undefined || data.resource === plan.resource)
|
|
207
|
+
&& (data.iss === undefined || data.iss === plan.issuer), 'TOKEN_RESOURCE_MISMATCH');
|
|
208
|
+
const granted = data.scope === undefined ? selected : scopeString(data.scope);
|
|
209
|
+
check(sameScopes(granted, selected), 'TOKEN_SCOPE_MISMATCH');
|
|
210
|
+
const refreshToken = data.refresh_token === undefined ? previous?.refreshToken : data.refresh_token;
|
|
211
|
+
check(secret(refreshToken), 'REFRESH_TOKEN_REQUIRED');
|
|
212
|
+
let expiresAt = at + data.expires_in * 1000;
|
|
213
|
+
check(Number.isSafeInteger(expiresAt), 'TOKEN_CONTRACT_MISMATCH');
|
|
214
|
+
if (data.access_token.split('.').length === 3) {
|
|
215
|
+
let header, claims;
|
|
216
|
+
try {
|
|
217
|
+
const [h, p, signature] = data.access_token.split('.');
|
|
218
|
+
check([h, p, signature].every(s => /^[A-Za-z0-9_-]+$/.test(s)), 'TOKEN_CLAIMS_INVALID');
|
|
219
|
+
header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8'));
|
|
220
|
+
claims = JSON.parse(Buffer.from(p, 'base64url').toString('utf8'));
|
|
221
|
+
} catch { fail('TOKEN_CLAIMS_INVALID'); }
|
|
222
|
+
// Binding checks only. The resource server verifies the signature and current grant.
|
|
223
|
+
check(typeof header?.alg === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(header.alg)
|
|
224
|
+
&& header.alg.toLowerCase() !== 'none' && claims?.iss === plan.issuer
|
|
225
|
+
&& (claims.aud === plan.resource || (Array.isArray(claims.aud) && claims.aud.length === 1 && claims.aud[0] === plan.resource))
|
|
226
|
+
&& Number.isSafeInteger(claims.exp) && claims.exp * 1000 > at
|
|
227
|
+
&& (claims.nbf === undefined || (Number.isSafeInteger(claims.nbf) && claims.nbf * 1000 <= at))
|
|
228
|
+
&& (claims.client_id === undefined || claims.client_id === plan.clientId)
|
|
229
|
+
&& (claims.azp === undefined || claims.azp === plan.clientId), 'TOKEN_CLAIMS_INVALID');
|
|
230
|
+
if (claims.scope !== undefined) check(sameScopes(scopeString(claims.scope), selected), 'TOKEN_SCOPE_MISMATCH');
|
|
231
|
+
expiresAt = Math.min(expiresAt, claims.exp * 1000);
|
|
232
|
+
}
|
|
233
|
+
return {accessToken: data.access_token, refreshToken, expiresAt, scope: granted.join(' ')};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function exchangeOAuthCode({plan, authorization, scopes, fetchImpl = fetch, now = Date.now}) {
|
|
237
|
+
requirePlan(plan);
|
|
238
|
+
const selected = requested(plan, scopes), bound = authorizations.get(authorization);
|
|
239
|
+
check(bound?.plan === plan && !bound.used && sameScopes(bound.scopes, selected), 'OAUTH_AUTHORIZATION_REQUIRED');
|
|
240
|
+
bound.used = true;
|
|
241
|
+
const {response, data} = await jsonRequest(fetchImpl, plan.tokenEndpoint, {method: 'POST',
|
|
242
|
+
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams({client_id: plan.clientId,
|
|
243
|
+
grant_type: 'authorization_code', code: authorization.code, code_verifier: authorization.verifier,
|
|
244
|
+
redirect_uri: authorization.redirectUri, resource: plan.resource})}, resolvers.get(plan));
|
|
245
|
+
check(response.ok, 'CODE_EXCHANGE_FAILED');
|
|
246
|
+
return retainTokens(plan, data, selected, now);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function refreshOAuthTokens({plan, tokens, fetchImpl = fetch, now = Date.now}) {
|
|
250
|
+
requirePlan(plan);
|
|
251
|
+
check(tokens && secret(tokens.refreshToken), 'REFRESH_TOKEN_REQUIRED');
|
|
252
|
+
const selected = requested(plan, scopeString(tokens.scope));
|
|
253
|
+
const {response, data} = await jsonRequest(fetchImpl, plan.tokenEndpoint, {method: 'POST',
|
|
254
|
+
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams({client_id: plan.clientId,
|
|
255
|
+
grant_type: 'refresh_token', refresh_token: tokens.refreshToken, scope: selected.join(' '), resource: plan.resource})}, resolvers.get(plan));
|
|
256
|
+
check(response.ok, 'REFRESH_FAILED');
|
|
257
|
+
// RFC6749 section6 permits omission of a replacement refresh token.
|
|
258
|
+
return retainTokens(plan, data, selected, now, tokens);
|
|
259
|
+
}
|
package/cli/setup-plan.js
CHANGED
|
@@ -147,18 +147,18 @@ function taskSteps(goal, mode, inspection, unsafe) {
|
|
|
147
147
|
: 'Gather customer-supplied site, audience, source and asset context before drafting.',
|
|
148
148
|
['Site and audience context', 'Source and claims rules'], ['A bounded content brief with verified source requirements and an existing content owner']));
|
|
149
149
|
steps.push(step('prepare-cited-content', 'customer_agent',
|
|
150
|
-
'
|
|
151
|
-
['
|
|
150
|
+
'Follow the customer’s existing content, source and review process with their chosen tools. Use packaged recipes only when explicitly selected; installed skills do not select a method or replace customer instructions. Keep unsupported claims and incomplete reviews unresolved. Record actual review outcomes, and verify the public result only after separately authorized publication. An authored result has no AgentLinkOps account prerequisite.',
|
|
151
|
+
['Authorized task scope', 'Customer-selected content process, source requirements and review policy'], ['Content artifact with supporting evidence and actual review outcomes; draft, reviewed and published states remain distinct, and publication requires public readback']));
|
|
152
152
|
} else {
|
|
153
153
|
steps.push(step('qualify-campaign-inputs', 'customer_agent',
|
|
154
154
|
'Inspect customer-selected records and assets; qualify exact source pages and claim-specific evidence. Preserve unknowns and duplicate references. An optional SQLite CRM is not required.',
|
|
155
155
|
['Customer-selected records', 'A relevant asset and campaign objective'], ['Qualified opportunities with exact source pages, evidence, duplicate accounting and unresolved claims']));
|
|
156
156
|
steps.push(step('prepare-campaign-handoff', 'customer_agent',
|
|
157
|
-
'Prepare an external-tool draft and reviewed mapping. Keep the customer’s CRM or sender responsible for contacts, suppression, reply stops and delivery
|
|
158
|
-
['Qualified opportunities', 'Chosen external tool and
|
|
157
|
+
'Prepare an external-tool draft and reviewed mapping using the customer’s source, editorial and review policy. Keep the customer’s CRM or sender responsible for contacts, suppression, reply stops and delivery. Packaged recipes are optional guidance only when explicitly selected and cannot override customer policy or authorize sending.',
|
|
158
|
+
['Qualified opportunities', 'Chosen external tool, supported fields and customer review policy'], ['Draft handoff with actual review outcomes, campaign references and supported external IDs; no sending or connection is implied']));
|
|
159
159
|
}
|
|
160
160
|
steps.push(step('record-resume-checkpoint', 'customer_agent',
|
|
161
|
-
'Prepare a checkpoint in the customer’s chosen record format
|
|
161
|
+
'Prepare or save a checkpoint in the customer’s chosen record format within their existing authorization. Preserve references, external IDs and completed evidence; distinguish a prepared checkpoint from a saved checkpoint with readback, and read back before replaying an external action.',
|
|
162
162
|
['Chosen record ownership and format'], ['Resumable next action; draft, sent, replied and independently verified placement states remain separate']));
|
|
163
163
|
return steps;
|
|
164
164
|
}
|
package/cli/status.js
CHANGED
|
@@ -13,11 +13,23 @@ export function disagreements(entries, observations) {
|
|
|
13
13
|
for (const entry of entries) {
|
|
14
14
|
const row = latest.get(entry.id);
|
|
15
15
|
if (!row) { out.push({ entry, kind: 'never_checked' }); continue; }
|
|
16
|
-
if (entry.intent === 'expected'
|
|
16
|
+
if (entry.intent === 'expected') {
|
|
17
|
+
const hostedState = row.source === 'cloud' ? row.result?.watchState : null;
|
|
18
|
+
const confirmed = row.state === 'confirmed_missing'
|
|
19
|
+
|| (row.state === 'absent' && hostedState === 'confirmed_missing');
|
|
20
|
+
const suspected = row.state === 'suspected_missing' || row.state === 'absent'
|
|
21
|
+
|| (row.state === 'unknown' && hostedState === 'suspected_missing');
|
|
22
|
+
const missing = ['absent', 'suspected_missing', 'confirmed_missing'].includes(row.state);
|
|
23
|
+
// The change mirror omits repeated checks, so raw absence cannot prove confirmation.
|
|
24
|
+
if ((confirmed || suspected) && row.complete === true) {
|
|
25
|
+
out.push({ entry, row, kind: confirmed ? 'lost' : 'suspected_missing' });
|
|
26
|
+
} else if (missing || ['unknown', 'source_unavailable'].includes(row.state)) {
|
|
27
|
+
out.push({ entry, row, kind: 'cannot_say' });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
17
30
|
// Nothing is promoted automatically: whether a link that appeared is one we now EXPECT is
|
|
18
31
|
// a judgement about a relationship, not about HTML. So this proposes the edit and stops.
|
|
19
32
|
if (entry.intent === 'wanted' && row.state === 'present') out.push({ entry, row, kind: 'appeared', suggest: { intent: 'expected' } });
|
|
20
|
-
if (entry.intent === 'expected' && row.state === 'unknown') out.push({ entry, row, kind: 'cannot_say' });
|
|
21
33
|
}
|
|
22
34
|
return out;
|
|
23
35
|
}
|
package/cli/sync-lock.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open, lstat, realpath, readFile, unlink } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { hostname } from 'node:os';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { ConfigError } from './config.js';
|
|
9
|
+
import { nativeLock } from './native-lock.js';
|
|
10
|
+
const run = promisify(execFile);
|
|
11
|
+
const MAX = 4096;
|
|
12
|
+
const unknown = () => new ConfigError('Sync lock ownership is unknown; marker preserved. Confirm the original writer has stopped and recover from a copy of the ledger. Do not delete a live lock.');
|
|
13
|
+
const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
14
|
+
async function markerText(handle) {
|
|
15
|
+
const buffer = Buffer.alloc(MAX + 1);
|
|
16
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
17
|
+
if (bytesRead > MAX) throw unknown();
|
|
18
|
+
return buffer.subarray(0, bytesRead).toString('utf8');
|
|
19
|
+
}
|
|
20
|
+
const validFile = s => s.isFile() && s.nlink === 1 && s.uid === process.getuid() && !(s.mode & 0o022);
|
|
21
|
+
async function identity() {
|
|
22
|
+
if (process.platform === 'linux') return (await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim();
|
|
23
|
+
if (process.platform === 'darwin') return (await run('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], { timeout: 2000, maxBuffer: MAX })).stdout.trim();
|
|
24
|
+
throw unknown();
|
|
25
|
+
}
|
|
26
|
+
async function birth() {
|
|
27
|
+
if (process.platform === 'linux') {
|
|
28
|
+
const stat = await readFile(`/proc/${process.pid}/stat`, 'utf8');
|
|
29
|
+
return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19];
|
|
30
|
+
}
|
|
31
|
+
return (await run('/bin/ps', ['-p', String(process.pid), '-o', 'lstart='], { timeout: 2000, maxBuffer: MAX })).stdout.trim();
|
|
32
|
+
}
|
|
33
|
+
// Unknown/reused live PIDs remain protected. ESRCH is the only reclaiming probe.
|
|
34
|
+
function departed(pid) {
|
|
35
|
+
try { process.kill(pid, 0); return false; }
|
|
36
|
+
catch (error) { if (error.code === 'ESRCH') return true; throw unknown(); }
|
|
37
|
+
}
|
|
38
|
+
export async function acquireSyncLock(dir, command, { kernel = nativeLock, bootIdentity = identity, processBirth = birth, isDeparted = departed, afterMarkerCreated, markerName = 'sync.lock' } = {}) {
|
|
39
|
+
if (typeof markerName !== 'string' || !markerName || markerName.length > 255 || /[\/\\\x00]/.test(markerName) || ['.', '..'].includes(markerName)) throw unknown();
|
|
40
|
+
const osLock = await kernel(); // Unsupported/native failure precedes filesystem mutation.
|
|
41
|
+
let root;
|
|
42
|
+
try { root = await realpath(dir); }
|
|
43
|
+
catch (error) { if (error.code === 'ENOENT') throw new ConfigError('No ledger here; run agentlinkops init first.'); throw unknown(); }
|
|
44
|
+
const rootInfo = await lstat(root);
|
|
45
|
+
if (!rootInfo.isDirectory() || rootInfo.uid !== process.getuid() || rootInfo.mode & 0o022) throw unknown();
|
|
46
|
+
const path = join(root, markerName), gatePath = join(root, markerName === 'sync.lock' ? '.sync-gate' : `.${markerName}.gate`);
|
|
47
|
+
const ledgerIdentity = join(root, markerName);
|
|
48
|
+
let gate, marker, owned, acquired = false;
|
|
49
|
+
try {
|
|
50
|
+
gate = await open(gatePath, constants.O_RDWR | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
|
51
|
+
const gateInfo = await gate.stat();
|
|
52
|
+
if (!validFile(gateInfo) || !same(gateInfo, await lstat(gatePath))) throw unknown();
|
|
53
|
+
if (!osLock.acquire(gate.fd)) throw new ConfigError('Sync writer is active; wait for it to finish. No lock was changed.');
|
|
54
|
+
acquired = true;
|
|
55
|
+
const checkGate = async () => {
|
|
56
|
+
const currentGate = await lstat(gatePath), currentRoot = await lstat(root);
|
|
57
|
+
if (!same(gateInfo, currentGate) || !validFile(currentGate) || !same(rootInfo, currentRoot) || currentRoot.uid !== process.getuid() || currentRoot.mode & 0o022) throw unknown();
|
|
58
|
+
};
|
|
59
|
+
await checkGate();
|
|
60
|
+
const boot = await bootIdentity(), started = await processBirth();
|
|
61
|
+
if (!boot || !started || boot.length > 256 || started.length > 256) throw unknown();
|
|
62
|
+
try { marker = await open(path, constants.O_RDWR | constants.O_NOFOLLOW); }
|
|
63
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
64
|
+
if (marker) {
|
|
65
|
+
const info = await marker.stat();
|
|
66
|
+
if (!validFile(info) || info.size > MAX || !same(info, await lstat(path))) throw unknown();
|
|
67
|
+
let prior;
|
|
68
|
+
try { prior = JSON.parse(await markerText(marker)); } catch { throw unknown(); }
|
|
69
|
+
if (Object.keys(prior).sort().join(',') !== 'boot,command,host,ledger,nonce,pid,started,uid,version'
|
|
70
|
+
|| prior.version !== 1 || prior.uid !== process.getuid() || prior.host !== hostname() || prior.boot !== boot
|
|
71
|
+
|| prior.ledger !== ledgerIdentity || !Number.isSafeInteger(prior.pid) || prior.pid < 1
|
|
72
|
+
|| typeof prior.started !== 'string' || !prior.started || prior.started.length > 256
|
|
73
|
+
|| !/^[0-9a-f-]{36}$/.test(prior.nonce) || typeof prior.command !== 'string' || prior.command.length > 64) throw unknown();
|
|
74
|
+
if (!isDeparted(prior.pid)) throw new ConfigError('Sync marker names a live or reused process; marker preserved. Wait for the writer or inspect a ledger copy.');
|
|
75
|
+
await checkGate();
|
|
76
|
+
if (!same(info, await lstat(path))) throw unknown();
|
|
77
|
+
await marker.close(); marker = null;
|
|
78
|
+
await unlink(path);
|
|
79
|
+
}
|
|
80
|
+
await checkGate();
|
|
81
|
+
marker = await open(path, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
82
|
+
await afterMarkerCreated?.();
|
|
83
|
+
owned = { version: 1, nonce: randomUUID(), pid: process.pid, started, boot, uid: process.getuid(), host: hostname(), ledger: ledgerIdentity, command };
|
|
84
|
+
await marker.writeFile(JSON.stringify(owned) + '\n');
|
|
85
|
+
await marker.sync();
|
|
86
|
+
const markerInfo = await marker.stat();
|
|
87
|
+
await checkGate();
|
|
88
|
+
return { path, async release() {
|
|
89
|
+
try {
|
|
90
|
+
await checkGate();
|
|
91
|
+
const current = await lstat(path);
|
|
92
|
+
if (!same(markerInfo, current) || !validFile(current)) throw unknown();
|
|
93
|
+
const text = await markerText(marker);
|
|
94
|
+
if (text !== JSON.stringify(owned) + '\n') throw unknown();
|
|
95
|
+
await unlink(path);
|
|
96
|
+
} finally { await marker.close(); await gate.close(); }
|
|
97
|
+
} };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// Failed/partial marker initialization is deliberately preserved as unknown.
|
|
100
|
+
await marker?.close(); await gate?.close();
|
|
101
|
+
if (error instanceof ConfigError) throw error;
|
|
102
|
+
if (error.code === 'ENOENT' && !acquired) throw new ConfigError('No ledger here; run agentlinkops init first.');
|
|
103
|
+
throw unknown();
|
|
104
|
+
}
|
|
105
|
+
}
|
package/cli/sync.js
CHANGED
|
@@ -93,7 +93,7 @@ export async function pullTargetEvents(client, state, options = {}) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/** The shared walk. One cursor key per feed, and the key is the only thing that differs. */
|
|
96
|
-
async function pullFeed(feed, cursorKey, state, { maxPages = 100, onSnapshot = null } = {}) {
|
|
96
|
+
async function pullFeed(feed, cursorKey, state, { maxPages = 100, onSnapshot = null, recoverInvalidCursor = false } = {}) {
|
|
97
97
|
const events = [], gaps = [];
|
|
98
98
|
let cursor = state.cursors?.[cursorKey] ?? null;
|
|
99
99
|
let pages = 0, resynced = false;
|
|
@@ -101,16 +101,20 @@ async function pullFeed(feed, cursorKey, state, { maxPages = 100, onSnapshot = n
|
|
|
101
101
|
let page;
|
|
102
102
|
try { page = await feed.list({ cursor, limit: 100 }); }
|
|
103
103
|
catch (error) {
|
|
104
|
-
|
|
104
|
+
const invalidBinding = recoverInvalidCursor === true && typeof cursor === 'string' && cursor.length > 0
|
|
105
|
+
&& error instanceof CloudError && error.code === 'INVALID_CURSOR' && error.status === 400;
|
|
106
|
+
if (!(error instanceof CloudError) || (error.code !== 'CURSOR_EXPIRED' && !invalidBinding)) throw error;
|
|
105
107
|
const details = error.details ?? {};
|
|
106
108
|
// The snapshot is taken FIRST. Only then does the cursor move, and the gap is recorded so
|
|
107
109
|
// a reader can see that something happened here rather than inferring quiet.
|
|
108
110
|
if (!feed.exportSnapshot) throw new CloudError('RESYNC_SNAPSHOT_UNAVAILABLE', 410, details);
|
|
109
111
|
if (resynced) throw new CloudError('RESYNC_REPEATED_EXPIRY', 410, details);
|
|
110
|
-
let snapshot = null, snapshotCursor = null;
|
|
112
|
+
let snapshot = null, snapshotCursor = null, snapshotPages = 0;
|
|
111
113
|
const seen = new Set();
|
|
112
114
|
do {
|
|
115
|
+
if (snapshotPages >= maxPages) throw new CloudError('RESYNC_SNAPSHOT_LIMIT', 502);
|
|
113
116
|
const part = await feed.exportSnapshot({cursor: snapshotCursor, limit: 100});
|
|
117
|
+
snapshotPages++;
|
|
114
118
|
snapshot = snapshot ? {...snapshot, items: [...snapshot.items, ...(part.items ?? [])]} : {...part, items: part.items ?? []};
|
|
115
119
|
snapshotCursor = part.next_cursor ?? null;
|
|
116
120
|
if (snapshotCursor && seen.has(snapshotCursor)) throw new CloudError('INVALID_SNAPSHOT_CURSOR', 502);
|
|
@@ -128,10 +132,13 @@ async function pullFeed(feed, cursorKey, state, { maxPages = 100, onSnapshot = n
|
|
|
128
132
|
data: {after}});
|
|
129
133
|
}
|
|
130
134
|
await onSnapshot?.(snapshot, cursorKey);
|
|
131
|
-
|
|
132
|
-
|
|
135
|
+
// An invalid binding supplies no trusted watermark. Snapshot current state first,
|
|
136
|
+
// then replay the retained feed under the client's unchanged configured scope.
|
|
137
|
+
const resume = invalidBinding ? null : details.resume_cursor ?? null;
|
|
138
|
+
gaps.push({ at: new Date().toISOString(), feed: cursorKey, reason: invalidBinding ? 'cursor_invalid' : 'cursor_expired', resumed_from: resume });
|
|
139
|
+
cursor = resume;
|
|
133
140
|
resynced = true;
|
|
134
|
-
if (!
|
|
141
|
+
if (!invalidBinding && !resume) break;
|
|
135
142
|
continue;
|
|
136
143
|
}
|
|
137
144
|
events.push(...(page.events ?? []));
|
|
@@ -139,6 +146,7 @@ async function pullFeed(feed, cursorKey, state, { maxPages = 100, onSnapshot = n
|
|
|
139
146
|
// Applied, then advanced.
|
|
140
147
|
cursor = page.next_cursor ?? cursor;
|
|
141
148
|
if (!page.has_more) break;
|
|
149
|
+
if (resynced && pages >= maxPages) throw new CloudError('RESYNC_EVENT_LIMIT', 502);
|
|
142
150
|
}
|
|
143
151
|
// Named by feed, so a caller writing this back cannot put it under the wrong key.
|
|
144
152
|
return { feed: cursorKey, events, cursor, pages, gaps, resynced };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlinkops",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.6.11",
|
|
4
|
+
"description": "Backlink checks with dated evidence and portable results, optional repository ledgers, supplier-export imports and sync to AgentLinkOps.",
|
|
5
5
|
"author": "AgentLinkOps (https://agentlinkops.com)",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"type": "module",
|
|
@@ -56,10 +56,13 @@
|
|
|
56
56
|
"jsonl"
|
|
57
57
|
],
|
|
58
58
|
"dependencies": {
|
|
59
|
+
"@modelcontextprotocol/client": "2.2.0",
|
|
59
60
|
"@modelcontextprotocol/server": "2.0.0",
|
|
61
|
+
"ipaddr.js": "2.2.0",
|
|
62
|
+
"koffi": "3.3.2",
|
|
60
63
|
"parse5": "8.0.1",
|
|
61
64
|
"turndown": "^7.2.4",
|
|
62
|
-
"undici": "7.29.
|
|
65
|
+
"undici": "7.29.1",
|
|
63
66
|
"zod": "4.6.0"
|
|
64
67
|
},
|
|
65
68
|
"devDependencies": {
|
package/references/cli.md
CHANGED
|
@@ -196,3 +196,25 @@ hide a history gap and is not a supported recovery procedure.
|
|
|
196
196
|
All success output is JSON except requested CSV export. Errors are JSON on
|
|
197
197
|
stderr with nonzero exit status. HTTP response bodies and credential values
|
|
198
198
|
are never included in errors. Local operation has no telemetry.
|
|
199
|
+
|
|
200
|
+
## Writer-lock recovery candidate
|
|
201
|
+
|
|
202
|
+
The maintained development source adds verified-owner recovery for `sync.lock`
|
|
203
|
+
and the ledger's secondary writer lock. This source acceptance is separate from
|
|
204
|
+
an installed release and hosted/client acceptance. A persistent OS advisory gate
|
|
205
|
+
serializes cooperating writers; valid same-user/same-boot metadata for a departed
|
|
206
|
+
writer can be recovered. Marker age alone never authorizes removal. Live, reused,
|
|
207
|
+
legacy empty, malformed or unverifiable markers are preserved with a refusal.
|
|
208
|
+
|
|
209
|
+
Keep the local ledger, mirror and state files. Read-only `status`/`report` remain
|
|
210
|
+
useful while writer ownership is unresolved. Inspect a copy; do not delete a live
|
|
211
|
+
lock or signal a process you did not create. An uncertain cloud request still
|
|
212
|
+
needs its original operation/readback before any write is retried; releasing a
|
|
213
|
+
local lock does not prove the remote request failed.
|
|
214
|
+
|
|
215
|
+
The candidate uses pinned prebuilt native assets for Darwin/Linux, with no compiler
|
|
216
|
+
fallback in the package build. Actual crash and relocated-package acceptance is
|
|
217
|
+
recorded for Darwin arm64; other architectures, Windows, musl and network-file
|
|
218
|
+
systems require separate proof. Unsupported/native-unavailable writers fail
|
|
219
|
+
closed with actionable setup diagnostics. Native lock acceptance does not enable
|
|
220
|
+
Darwin OAuth custody or establish customer credentials/grants.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Local Outreach CRM draft handoff
|
|
2
|
+
|
|
3
|
+
Use only when the customer selects a local Outreach CRM with an existing
|
|
4
|
+
`projects/<crm_project>/config.json` and `drafts/` directory. The config must contain
|
|
5
|
+
that exact `project` and a `site` URL with the same hostname as the request.
|
|
6
|
+
This adapter writes an inert body and companion manifest under that project's
|
|
7
|
+
drafts directory. It does not write contacts, replies, suppression, send logs or
|
|
8
|
+
sequence state. Native vendor APIs remain unsupported by this adapter.
|
|
9
|
+
|
|
10
|
+
Run from the plugin root:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
python3 scripts/outreach-crm-draft.py --crm-root /path/to/customer-crm --request request.json --body-file reviewed-body.txt
|
|
14
|
+
python3 scripts/outreach-crm-draft.py --crm-root /path/to/customer-crm --request request.json --body-file reviewed-body.txt --readback
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`request.json` contains exactly these fields; use actual saved identities rather
|
|
18
|
+
than copying the example values:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"version": 1,
|
|
23
|
+
"crm_project": "customer-project",
|
|
24
|
+
"workspace_id": "saved-workspace",
|
|
25
|
+
"project_id": "saved-project",
|
|
26
|
+
"site": "https://customer.invalid",
|
|
27
|
+
"operation_id": "original-operation",
|
|
28
|
+
"original_request": {
|
|
29
|
+
"destination": "saved-service-destination",
|
|
30
|
+
"workspace_id": "saved-workspace",
|
|
31
|
+
"project_id": "saved-project",
|
|
32
|
+
"command": "saved-command",
|
|
33
|
+
"arguments": {"saved-non-secret-argument": "saved-value"},
|
|
34
|
+
"idempotency_key": "original-request-key"
|
|
35
|
+
},
|
|
36
|
+
"local_id": "saved-local-record",
|
|
37
|
+
"external_id": "saved-crm-record",
|
|
38
|
+
"observation": "unknown",
|
|
39
|
+
"reply": "unknown",
|
|
40
|
+
"suppression": "unknown",
|
|
41
|
+
"subject": "Reviewed draft subject",
|
|
42
|
+
"body_sha256": "exact-sha256-of-reviewed-body-bytes"
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Preserve the original request tuple from its checkpoint, including its exact
|
|
47
|
+
non-secret arguments. Never place credentials in it. Workspace/project binding
|
|
48
|
+
is customer supplied; this helper does not authenticate it against hosted grants
|
|
49
|
+
or confirm that an external ID exists in CRM contacts. Its local config check
|
|
50
|
+
prevents an explicit site/project mismatch, not a forged customer mapping.
|
|
51
|
+
|
|
52
|
+
`observation` is `unknown`, `present`, `provisional_loss` or `confirmed_loss`;
|
|
53
|
+
`reply` is `unknown`, `none` or `replied`; `suppression` is `unknown`, `clear` or
|
|
54
|
+
`suppressed`. These are retained observations, not refreshed sender decisions.
|
|
55
|
+
Drafts always have `send_eligible: false`, including apparently clear rows.
|
|
56
|
+
Follow the qualified campaign recipe's content review before saving a real draft.
|
|
57
|
+
|
|
58
|
+
The original operation and local/external identities determine one stable draft
|
|
59
|
+
directory. The helper verifies the supplied UTF-8 body hash, publishes `body.txt`
|
|
60
|
+
and `manifest.json`, then rereads their exact bytes. An identical retry reports
|
|
61
|
+
`identical_readback`. If interrupted between files, repeat the original save;
|
|
62
|
+
it completes the missing artifact without replacing the existing body. A changed
|
|
63
|
+
subject, body, request or mapping at that identity refuses rather than overwrites.
|
|
64
|
+
Inspect and preserve the conflicting original artifact; a legitimate new operation
|
|
65
|
+
requires a separately recorded identity, not a fabricated replay key.
|
|
66
|
+
|
|
67
|
+
`--readback` verifies both artifacts and refuses missing or partial saves. It does
|
|
68
|
+
not create a draft directory. Keep the receipt's operation, destination directory,
|
|
69
|
+
body and manifest hashes with the customer's checkpoint. Saving here is actual
|
|
70
|
+
local filesystem destination readback, not a mailbox/vendor acknowledgement.
|
|
71
|
+
|
|
72
|
+
Publication reuses the portable helper's cooperating-writer locking and ExFAT
|
|
73
|
+
fallback. Destination symlinks are refused. This does not sandbox arbitrary local
|
|
74
|
+
applications or prove power-loss durability. No email system or network dependency
|
|
75
|
+
is installed. Current sender reply/suppression/quota/mailbox gates must still be
|
|
76
|
+
checked by the customer's existing sender at its separately authorized send step.
|