agentlinkops 0.6.8 → 0.6.10

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.
Files changed (54) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +20 -0
  4. package/README.md +44 -12
  5. package/cli/admission.js +9 -1
  6. package/cli/agent-setup.js +189 -20
  7. package/cli/catalog.js +7938 -1357
  8. package/cli/client.js +89 -23
  9. package/cli/cloud-error.js +21 -0
  10. package/cli/commands.js +37 -6
  11. package/cli/connection.js +74 -12
  12. package/cli/doctor.js +24 -9
  13. package/cli/gsc-browser.js +19 -6
  14. package/cli/ledger.js +4 -7
  15. package/cli/lifecycle.js +5 -2
  16. package/cli/local-result.js +4 -4
  17. package/cli/main.js +43 -28
  18. package/cli/mcp-client.js +198 -0
  19. package/cli/native-lock.js +22 -0
  20. package/cli/oauth-credentials.js +196 -0
  21. package/cli/oauth-flow.js +259 -0
  22. package/cli/setup-plan.js +5 -5
  23. package/cli/status.js +14 -2
  24. package/cli/sync-lock.js +105 -0
  25. package/cli/sync.js +14 -6
  26. package/package.json +6 -3
  27. package/references/cli.md +22 -0
  28. package/references/local-crm-draft-handoff.md +76 -0
  29. package/references/portable-ledger-handoff.md +50 -0
  30. package/references/recipes/catalog.json +9 -9
  31. package/references/recipes/command-notes.md +17 -1
  32. package/references/recipes/placement-reconciliation.md +19 -5
  33. package/references/recipes/qualified-campaign-handoff.md +16 -4
  34. package/references/recipes/site-context-brief.md +2 -2
  35. package/references/recipes/sourced-linkable-asset.md +8 -8
  36. package/scripts/agentlinkops.py +30 -5
  37. package/scripts/outreach-crm-draft.py +121 -0
  38. package/scripts/portable-handoff.py +184 -0
  39. package/shared/exec-grammar.js +342 -0
  40. package/shared/lifecycle-contract.js +2 -2
  41. package/shared/response-contract.js +7 -1
  42. package/skills/agentlinkops-assets/SKILL.md +2 -2
  43. package/skills/agentlinkops-campaigns/SKILL.md +3 -3
  44. package/skills/agentlinkops-connect/SKILL.md +29 -6
  45. package/skills/agentlinkops-connect/references/agentlinkops.md +62 -26
  46. package/skills/agentlinkops-crm/SKILL.md +17 -3
  47. package/src/citations/adapter.js +3 -2
  48. package/src/citations/browser/chatgpt-citations.js +60 -6
  49. package/src/citations/browser/engine.js +106 -18
  50. package/src/citations/browser/shims/utils.js +9 -2
  51. package/src/citations/login.js +42 -5
  52. package/src/discovery/contract.js +1 -1
  53. package/src/response-shaping.js +58 -0
  54. package/src/tool-views.js +31 -4
@@ -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
- 'Use the site’s content router when present, then source-cited-content-builder, source-cited-humanizer, content-defingerprinting, internal-linking-optimizer, applicable image workflow and pre-publish-review. An authored result has no AgentLinkOps account prerequisite.',
151
- ['Approved task scope', 'Applicable content skills and primary-source evidence'], ['Cited content artifact and recorded review gates; draft, reviewed and published states remain distinct']));
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; apply required content gates to authored copy.',
158
- ['Qualified opportunities', 'Chosen external tool and its supported fields'], ['Draft handoff with campaign references and supported external IDs; no sending or connection is implied']));
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 for later approval to save. Preserve references, external IDs and completed evidence; read back before replaying an external action.',
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' && ['absent','confirmed_missing'].includes(row.state) && row.complete) out.push({ entry, row, kind: 'lost' });
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
  }
@@ -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
- if (!(error instanceof CloudError) || error.code !== 'CURSOR_EXPIRED') throw error;
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
- gaps.push({ at: new Date().toISOString(), reason: 'cursor_expired', resumed_from: details.resume_cursor ?? null });
132
- cursor = details.resume_cursor ?? null;
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 (!details.resume_cursor) break;
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.8",
4
- "description": "A backlink and AI citation ledger that lives in your repository: local link and citation checks with dated evidence, supplier-export imports and optional sync to the hosted AgentLinkOps service.",
3
+ "version": "0.6.10",
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.0",
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.