agentlinkops 0.6.9 → 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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +11 -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 +15 -3
  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 +42 -27
  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/lifecycle-contract.js +2 -2
  40. package/shared/response-contract.js +7 -1
  41. package/skills/agentlinkops-assets/SKILL.md +2 -2
  42. package/skills/agentlinkops-campaigns/SKILL.md +3 -3
  43. package/skills/agentlinkops-connect/SKILL.md +29 -6
  44. package/skills/agentlinkops-connect/references/agentlinkops.md +34 -13
  45. package/skills/agentlinkops-crm/SKILL.md +17 -3
  46. package/src/citations/adapter.js +3 -2
  47. package/src/citations/browser/engine.js +23 -7
  48. package/src/citations/login.js +42 -5
  49. package/src/discovery/contract.js +1 -1
  50. package/src/response-shaping.js +58 -0
  51. package/src/tool-views.js +3 -1
package/cli/client.js CHANGED
@@ -1,44 +1,110 @@
1
1
  import {restResponseContract} from '../shared/response-contract.js';
2
+ import {CloudError} from './cloud-error.js';
3
+ import {createMcpClient} from './mcp-client.js';
4
+ export {CloudError} from './cloud-error.js';
2
5
  // A small HTTP client for the cloud. Scoped keys only; no human session ever reaches here.
3
6
  // DP-0036-T13: the CLI names itself so the usage counters can tell it from other REST callers.
4
7
  // The version follows the plugin manifests; bump it with them.
5
- export const CLI_VERSION = '0.6.9';
8
+ export const CLI_VERSION = '0.6.10';
6
9
  export const CLI_USER_AGENT = `agentlinkops-cli/${CLI_VERSION}`;
7
- export class CloudError extends Error {
8
- constructor(code, status, details = null) {
9
- super(`${code}${status ? ` (${status})` : ''}`);
10
- this.name = 'CloudError'; this.code = code; this.status = status; this.details = details;
11
- this.exitCode = 2;
12
- }
13
- }
14
-
15
- export function createClient({ origin, token, workspaceId, projectId, fetchImpl = globalThis.fetch } = {}) {
10
+ const MAX_ATTEMPTS = 3;
11
+ const MAX_RETRY_WAIT_MS = 5_000;
12
+ const retryableStatus = new Set([429, 503]);
13
+ const sleepDefault = ms => new Promise(resolve => setTimeout(resolve, ms));
14
+ const retryAfterMs = (value, now) => {
15
+ if (value == null || value === '') return null;
16
+ if (/^\d+$/u.test(value.trim())) return Number(value.trim()) * 1000;
17
+ const at = Date.parse(value);
18
+ return Number.isFinite(at) ? Math.max(0, at - now) : null;
19
+ };
20
+ const backoffMs = attempt => Math.min(MAX_RETRY_WAIT_MS, 250 * 2 ** (attempt - 1));
21
+ const isTimeout = error => ['TimeoutError', 'AbortError'].includes(error?.name) || error?.code === 'ETIMEDOUT';
22
+ export function createClient({ origin, token, workspaceId, projectId, fetchImpl = globalThis.fetch,
23
+ sleepImpl = sleepDefault, now = () => Date.now(), auth, credentialStore, createSdkClient, resolveHost } = {}) {
16
24
  if (!origin) throw new CloudError('NO_CLOUD_ORIGIN', 0);
25
+ if (auth !== undefined && auth?.kind !== 'oauth') throw new CloudError('OAUTH_CONFIG_INVALID', 400);
26
+ if (auth?.kind === 'oauth') {
27
+ if (token) throw new CloudError('OAUTH_CREDENTIAL_CONFLICT', 400);
28
+ return createMcpClient({origin, workspaceId, projectId, fetchImpl, sleepImpl, now, auth, credentialStore, createSdkClient, resolveHost});
29
+ }
17
30
  if (!token) throw new CloudError('NO_TOKEN', 0);
18
31
  const base = origin.replace(/\/$/u, '');
19
32
  async function call(method, path, { body = null, query = null } = {}) {
20
33
  const url = new URL(base + path);
21
34
  for (const [key, value] of Object.entries(query ?? {})) if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
22
- const response = await fetchImpl(url.href, {
23
- method, redirect: 'error', signal: AbortSignal.timeout(30000),
24
- headers: {
25
- Authorization: `Bearer ${token}`,
26
- 'User-Agent': CLI_USER_AGENT,
27
- ...(workspaceId ? { 'X-Workspace-ID': workspaceId } : {}),
28
- ...(body ? { 'Content-Type': 'application/json' } : {}),
29
- },
30
- ...(body ? { body: JSON.stringify(body) } : {}),
31
- });
32
- const text = await response.text();
35
+ const safeRead = method.toUpperCase() === 'GET';
36
+ let response, text, retryAfter = null, requestId = null;
37
+ for (let attempt = 1; attempt <= (safeRead ? MAX_ATTEMPTS : 1); attempt++) {
38
+ response = undefined; text = undefined; retryAfter = null; requestId = null;
39
+ try {
40
+ response = await fetchImpl(url.href, {
41
+ method, redirect: 'error', signal: AbortSignal.timeout(30000),
42
+ headers: {
43
+ Authorization: `Bearer ${token}`,
44
+ 'User-Agent': CLI_USER_AGENT,
45
+ ...(workspaceId ? { 'X-Workspace-ID': workspaceId } : {}),
46
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
47
+ },
48
+ ...(body ? { body: JSON.stringify(body) } : {}),
49
+ });
50
+ retryAfter = response.headers.get('Retry-After');
51
+ requestId = response.headers.get('X-Request-ID') ?? requestId;
52
+ text = await response.text();
53
+ } catch (error) {
54
+ if (safeRead && attempt < MAX_ATTEMPTS) {
55
+ if (response && retryableStatus.has(response.status)) {
56
+ const serverDelay = retryAfterMs(retryAfter, now());
57
+ const delay = serverDelay === null ? backoffMs(attempt)
58
+ : serverDelay <= MAX_RETRY_WAIT_MS ? serverDelay : null;
59
+ if (delay !== null) {
60
+ await sleepImpl(delay);
61
+ continue;
62
+ }
63
+ throw new CloudError(`HTTP_${response.status}`, response.status, null,
64
+ 'The server returned a temporary response and requested a longer wait. Retry after the stated interval.',
65
+ null, { retryAfter, requestId });
66
+ }
67
+ await sleepImpl(backoffMs(attempt));
68
+ continue;
69
+ }
70
+ if (response && !response.ok) {
71
+ throw new CloudError(`HTTP_${response.status}`, response.status, null,
72
+ 'The server response could not be read completely. Check the request result before trying again.',
73
+ null, { retryAfter, requestId });
74
+ }
75
+ const timedOut = isTimeout(error);
76
+ const code = timedOut ? 'CLOUD_TIMEOUT' : 'CLOUD_NETWORK_ERROR';
77
+ const message = timedOut
78
+ ? safeRead ? `The cloud read timed out after ${attempt} attempt${attempt === 1 ? '' : 's'}. Check the connection before trying again.` : 'The cloud request timed out. Check the operation result before sending it again.'
79
+ : safeRead ? `The cloud connection failed after ${attempt} read attempt${attempt === 1 ? '' : 's'}. Check the connection before trying again.` : 'The cloud connection failed before the request returned a response. Check the operation result before sending it again.';
80
+ throw new CloudError(code, 0, { attempts: attempt }, message, null, { requestId, retryAfter });
81
+ }
82
+
83
+ if (safeRead && retryableStatus.has(response.status) && attempt < MAX_ATTEMPTS) {
84
+ const serverDelay = retryAfterMs(retryAfter, now());
85
+ const delay = serverDelay === null ? backoffMs(attempt)
86
+ : serverDelay <= MAX_RETRY_WAIT_MS ? serverDelay : null;
87
+ // A Retry-After longer than our bounded wait is never shortened into an early retry.
88
+ if (delay !== null) {
89
+ await sleepImpl(delay);
90
+ continue;
91
+ }
92
+ }
93
+ break;
94
+ }
33
95
  let payload = null;
34
96
  try { payload = text ? JSON.parse(text) : null; } catch { payload = null; }
35
97
  if (!response.ok) {
36
98
  // The error BODY is the useful part — a 410 carries the resync instructions, and losing
37
99
  // them would turn a recoverable expiry into "the sync failed, try again forever".
38
- throw new CloudError(payload?.error?.code ?? `HTTP_${response.status}`, response.status, payload?.error?.details ?? null);
100
+ const code = payload?.error?.code ?? `HTTP_${response.status}`;
101
+ const id = payload?.error?.requestId ?? requestId;
102
+ const envelope = payload?.error ? { ...payload.error } : null;
103
+ throw new CloudError(code, response.status, payload?.error?.details ?? null,
104
+ payload?.error?.message ?? null, envelope, { retryAfter, requestId: id });
39
105
  }
40
106
  const parsed=restResponseContract(path,method).safeParse(payload);
41
- if(!parsed.success)throw new CloudError('INVALID_RESPONSE',502);
107
+ if(!parsed.success)throw new CloudError('INVALID_RESPONSE',502,null,null,null,{requestId});
42
108
  return parsed.data;
43
109
  }
44
110
  return {
@@ -0,0 +1,21 @@
1
+ export class CloudError extends Error {
2
+ constructor(code, status, details = null, serverMessage = null, envelope = null, metadata = {}) {
3
+ super(`${code}${status ? ` (${status})` : ''}`);
4
+ this.name = 'CloudError'; this.code = code; this.status = status; this.details = details;
5
+ // DP-0063-T03: the server's message says what to fix; keep it for the CLI to print.
6
+ this.serverMessage = typeof serverMessage === 'string' ? serverMessage : null;
7
+ // Keep the public server envelope structured; the CLI must not stringify issues into a message.
8
+ this.retryAfter = metadata.retryAfter ?? null;
9
+ this.requestId = metadata.requestId ?? envelope?.requestId ?? null;
10
+ this.publicError = envelope ? { ...envelope,
11
+ ...(this.requestId && !envelope.requestId ? { requestId: this.requestId } : {}),
12
+ ...(this.retryAfter !== null ? { retryAfter: this.retryAfter } : {}),
13
+ } : this.requestId || this.retryAfter !== null || this.serverMessage ? {
14
+ code, ...(this.serverMessage ? { message: this.serverMessage } : {}),
15
+ ...(this.details ? { details: this.details } : {}),
16
+ ...(this.requestId ? { requestId: this.requestId } : {}),
17
+ ...(this.retryAfter !== null ? { retryAfter: this.retryAfter } : {}),
18
+ } : null;
19
+ this.exitCode = 2;
20
+ }
21
+ }
package/cli/commands.js CHANGED
@@ -13,7 +13,8 @@ import { resolveSchemaPath, listAvailablePaths } from '../shared/exec-grammar.js
13
13
  // describe (`describe NAME`) and call (`call NAME`). `tools` and `describe` answer from the
14
14
  // bundled snapshot in cli/catalog.js with no account and no network; `--refresh` reads the
15
15
  // live catalog instead. Output is JSON whenever stdout is not a terminal or --json is given,
16
- // and one error shape ({ error: { code, message, next } }) on every failure.
16
+ // and structured public errors. JSON preserves the public envelope; terminal rendering can
17
+ // supply local guidance when the server has none.
17
18
  const USAGE = 'tools [TOOLSET] [--json] [--refresh] | describe NAME [--examples] [--output-schema] [--json] [--refresh] | schema NAME [FIELD.PATH] [--json] | call NAME [--args JSON | --file FILE | --set PATH=VALUE ...] [--dry-run] [-y] [--json]';
18
19
  const NAME = /^[a-z][a-z0-9_]*$/;
19
20
  const NEXT = {
@@ -104,6 +105,17 @@ export async function commandsMain(argv, { cwd = process.cwd(), env = process.en
104
105
  const [command, name] = args._;
105
106
  const json = args.json === true || !isTTY;
106
107
  const fail = (code, message) => { if (json) err(errorJson(code, message, explain(code))); else err(`${message}\n${explain(code)}`); return 2; };
108
+ const cloudFail = (error, context) => {
109
+ if (!error.publicError) return fail(error.code, `${context}: ${error.code}${error.status ? ` (${error.status})` : ''}`);
110
+ if (json) {
111
+ err(JSON.stringify({ error: error.publicError }));
112
+ }
113
+ else {
114
+ const next = [error.publicError.next, error.details?.next].find(value => typeof value === 'string' && value.trim()) ?? explain(error.code);
115
+ err(`${context}: ${error.code}${error.status ? ` (${error.status})` : ''}\n${error.serverMessage ?? error.code}\n${next}`);
116
+ }
117
+ return 2;
118
+ };
107
119
  const allowed = { tools: ['_', 'json', 'refresh'], describe: ['_', 'json', 'refresh', 'examples', 'output-schema'], schema: ['_', 'json'], call: ['_', 'args', 'file', 'set', 'dry-run', 'json'] };
108
120
  const positionalCount = command === 'tools' ? args._.length > 2 : command === 'schema' ? args._.length !== 2 && args._.length !== 3 : args._.length !== 2;
109
121
  if (!allowed[command] || Object.keys(args).some(key => !allowed[command].includes(key)) || positionalCount || (command !== 'tools' && !NAME.test(name))) throw new ConfigError(USAGE);
@@ -142,7 +154,7 @@ export async function commandsMain(argv, { cwd = process.cwd(), env = process.en
142
154
  const live = await client.listCommands();
143
155
  commands = live.items.map(item => ({ ...item, readOnly: item.annotations?.readOnlyHint === true, admissionGated: false }));
144
156
  } catch (error) {
145
- if (error instanceof CloudError) return fail(error.code, `catalog refresh: ${error.code}${error.status ? ` (${error.status})` : ''}`);
157
+ if (error instanceof CloudError) return cloudFail(error, 'catalog refresh');
146
158
  throw error;
147
159
  }
148
160
  }
@@ -196,7 +208,7 @@ export async function commandsMain(argv, { cwd = process.cwd(), env = process.en
196
208
  out(JSON.stringify(result, null, 2));
197
209
  return 0;
198
210
  } catch (error) {
199
- if (error instanceof CloudError) return fail(error.code, `${canonical}: ${error.code}${error.status ? ` (${error.status})` : ''}${error.details ? ` ${JSON.stringify(error.details)}` : ''}`);
211
+ if (error instanceof CloudError) return cloudFail(error, canonical);
200
212
  throw error;
201
213
  }
202
214
  }
package/cli/connection.js CHANGED
@@ -1,9 +1,12 @@
1
+ import { acquireSyncLock } from './sync-lock.js';
1
2
  import { readFile, writeFile, rename, unlink, open } from 'node:fs/promises';
2
3
  import { join, resolve } from 'node:path';
3
4
  import { loadConfig, ConfigError } from './config.js';
4
5
  import { createClient } from './client.js';
5
6
  import { readLedger } from './ledger.js';
6
7
  import { resolveEnv } from './env.js';
8
+ import { createOAuthCredentialStore } from './oauth-credentials.js';
9
+ import { discoverOAuth, receiveOAuthConsent, exchangeOAuthCode } from './oauth-flow.js';
7
10
 
8
11
  export const SYNC_SCOPES = ['projects:read', 'watches:read', 'watches:write', 'events:read', 'exports:create'];
9
12
 
@@ -19,18 +22,33 @@ export function cloudConnection(config, env = process.env) {
19
22
  throw new ConfigError(`${sources.API_URL} disagrees with the saved cloud origin.`);
20
23
  if (new Set([config.cloud?.workspaceId, config.cloud?.workspace_id].filter(Boolean)).size > 1 || new Set([config.project?.id, config.cloud?.projectId, config.cloud?.project_id].filter(Boolean)).size > 1)
21
24
  throw new ConfigError('Saved cloud identity fields disagree.');
22
- return { origin: values.API_URL || config.cloud?.origin,
23
- token: values.TOKEN || values.API_KEY || config.cloud?.token,
25
+ const origin = values.API_URL || config.cloud?.origin;
26
+ const token = values.TOKEN || values.API_KEY || config.cloud?.token;
27
+ const auth = config.cloud?.auth;
28
+ if (auth !== undefined) {
29
+ if (!auth || auth.kind !== 'oauth' || token || Object.keys(auth).sort().join(',') !== 'clientId,credentialRef,issuer,kind,resource')
30
+ throw new ConfigError('Saved OAuth connection conflicts with an API key or has an invalid authentication mode.');
31
+ let resource, issuer;
32
+ try { resource = new URL(auth.resource); issuer = new URL(auth.issuer); } catch { throw new ConfigError('Saved OAuth binding is invalid.'); }
33
+ if (resource.href !== `${origin}/mcp` || issuer.protocol !== 'https:' || auth.issuer !== issuer.origin
34
+ || typeof auth.clientId !== 'string' || !auth.clientId || auth.clientId.length > 2048 || /[\s\x00-\x1f\x7f]/.test(auth.clientId)
35
+ || !/^oauth_[a-f0-9]{32}$/.test(auth.credentialRef ?? ''))
36
+ throw new ConfigError('Saved OAuth binding is invalid.');
37
+ }
38
+ return { origin, ...(auth ? { auth: { ...auth } } : {}),
39
+ token,
24
40
  tokenSource: values.TOKEN ? sources.TOKEN : values.API_KEY ? sources.API_KEY : null,
25
41
  workspaceId: config.cloud?.workspaceId || config.cloud?.workspace_id, projectId: config.project?.id || config.cloud?.projectId || config.cloud?.project_id };
26
42
  }
27
43
 
28
44
  export function keySetup(origin) {
29
- return `Open ${origin}/app, select the workspace, then Agent access > Create API key. Select the project and scopes ${SYNC_SCOPES.join(', ')}. Supply the key through AGENTLINKOPS_TOKEN (AGENTLINKOPS_API_KEY also works; the LINKTRAIL_ spellings remain accepted during the pilot compatibility window), then rerun connect. MCP OAuth credentials remain in the MCP client.`;
45
+ return `Open ${origin}/app, select the workspace, then Agent access > Create API key. Select the project and scopes ${SYNC_SCOPES.join(', ')}. Supply the key through AGENTLINKOPS_TOKEN (AGENTLINKOPS_API_KEY also works; the LINKTRAIL_ spellings remain accepted during the pilot compatibility window), then rerun connect. For CLI OAuth on Linux, use connect --oauth with an existing public client; credentials stay in the user-owned POSIX-mode store. On other platforms, use OAuth in your MCP client. MCP clients manage their own credentials.`;
30
46
  }
31
47
 
32
48
  // Verify an existing project before saving only non-secret connection metadata.
33
- export async function connectMain(args, { cwd, env = process.env, out = console.log, fetchImpl = globalThis.fetch } = {}) {
49
+ export async function connectMain(args, { cwd, env = process.env, out = console.log, fetchImpl = globalThis.fetch,
50
+ credentialStore = createOAuthCredentialStore(), oauth = { discoverOAuth, receiveOAuthConsent, exchangeOAuthCode },
51
+ onAuthorization = ({ authorizationUrl }) => out(`Authorize the connection: ${authorizationUrl}`), clientFactory = createClient } = {}) {
34
52
  const config = await loadConfig({ cwd });
35
53
  const connection = cloudConnection(config, env);
36
54
  const origin = args.origin || connection.origin || 'https://app.agentlinkops.com';
@@ -40,8 +58,13 @@ export async function connectMain(args, { cwd, env = process.env, out = console.
40
58
  try { parsed = new URL(origin); } catch { throw new ConfigError('connect --origin requires an HTTPS origin.'); }
41
59
  if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash || parsed.pathname !== '/')
42
60
  throw new ConfigError('connect --origin requires an HTTPS origin without a path.');
43
- if (!connection.token) { out(keySetup(parsed.origin)); return 2; }
44
- if (!/^lt_[0-9a-f]{64}$/.test(connection.token)) throw new ConfigError('Supply a complete API key through AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY.');
61
+ const oauthRequested = args.oauth === true || connection.auth?.kind === 'oauth';
62
+ if (args.oauth !== undefined && args.oauth !== true) throw new ConfigError('--oauth does not take a value.');
63
+ if (!oauthRequested && ['issuer', 'client-id', 'credential-ref'].some(key => args[key] !== undefined))
64
+ throw new ConfigError('OAuth options require --oauth.');
65
+ if (oauthRequested && connection.token) throw new ConfigError('Remove API-key credentials before selecting OAuth.');
66
+ if (!oauthRequested && !connection.token) { out(keySetup(parsed.origin)); return 2; }
67
+ if (!oauthRequested && !/^lt_[0-9a-f]{64}$/.test(connection.token)) throw new ConfigError('Supply a complete API key through AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY.');
45
68
  if (typeof workspaceId !== 'string' || !workspaceId || typeof projectId !== 'string' || !projectId)
46
69
  throw new ConfigError('connect requires --workspace ID and --project-id ID from the existing cloud project.');
47
70
  if (config.cloud?.origin && new URL(config.cloud.origin).origin !== parsed.origin || connection.workspaceId && connection.workspaceId !== workspaceId || connection.projectId && connection.projectId !== projectId)
@@ -56,35 +79,74 @@ export async function connectMain(args, { cwd, env = process.env, out = console.
56
79
  throw new ConfigError('Selection must name existing ledger IDs in a valid ledger.');
57
80
  ledgerIds = [...new Set(ledgerIds)];
58
81
  }
59
- const client = createClient({ origin: parsed.origin, token: connection.token, workspaceId, fetchImpl });
82
+ let auth = connection.auth, createdRef, committed = false;
83
+ if (oauthRequested) {
84
+ try { await credentialStore.checkAvailability?.(); }
85
+ catch (error) {
86
+ if (error?.code !== 'OAUTH_CREDENTIAL_STORAGE_UNSUPPORTED') throw error;
87
+ throw Object.assign(new ConfigError('CLI OAuth credential storage is not supported on this platform. Use an API-key connection or OAuth in your MCP client until native custody is verified.'), {code: error.code});
88
+ }
89
+ const issuer = args.issuer || auth?.issuer, clientId = args['client-id'] || auth?.clientId;
90
+ if (!issuer || !clientId) throw new ConfigError('OAuth requires --issuer URL and --client-id ID for an existing public client.');
91
+ const binding = { issuer, resource: `${parsed.origin}/mcp`, clientId };
92
+ if (auth && (auth.issuer !== issuer || auth.clientId !== clientId || auth.resource !== binding.resource
93
+ || args['credential-ref'] && args['credential-ref'] !== auth.credentialRef))
94
+ throw new ConfigError('OAuth binding changed; use a separate ledger.');
95
+ const credentialRef = args['credential-ref'] || auth?.credentialRef;
96
+ if (credentialRef) {
97
+ await credentialStore.read(credentialRef, binding);
98
+ auth = { kind: 'oauth', ...binding, credentialRef };
99
+ } else {
100
+ const scopes = [...SYNC_SCOPES, 'offline_access'];
101
+ const plan = await oauth.discoverOAuth({ origin: parsed.origin, issuer, clientId, scopes, fetchImpl });
102
+ const authorization = await oauth.receiveOAuthConsent({ plan, scopes, onAuthorization });
103
+ const tokens = await oauth.exchangeOAuthCode({ plan, authorization, scopes, fetchImpl });
104
+ createdRef = await credentialStore.create(binding, tokens);
105
+ auth = { kind: 'oauth', ...binding, credentialRef: createdRef };
106
+ }
107
+ }
108
+ try {
109
+ const client = clientFactory({ origin: parsed.origin, token: connection.token, ...(auth ? {auth, credentialStore} : {}), workspaceId, projectId, fetchImpl });
60
110
  const account = await client.getWorkspace();
61
111
  if (account.workspace?.id !== workspaceId) throw new ConfigError('Cloud workspace does not match the selected workspace.');
62
- const missing = SYNC_SCOPES.filter(scope => !account.access?.scopes?.includes(scope));
112
+ // MCP readback does not expose an authoritative effective write grant.
113
+ const missing = auth ? [] : SYNC_SCOPES.filter(scope => !account.access?.scopes?.includes(scope));
63
114
  if (missing.length) throw new ConfigError(`Key lacks sync scopes: ${missing.join(', ')}.`);
64
115
  const project = await client.getProject(projectId);
65
116
  if (project.id !== projectId || project.workspace_id !== workspaceId) throw new ConfigError('Cloud project does not match the selected workspace.');
66
- const lockPath = join(config.dir, 'sync.lock');
67
- const lock = await open(lockPath, 'wx');
117
+ const lock = await acquireSyncLock(config.dir, 'connect');
68
118
  const file = join(config.dir, 'config.json'), temporary = join(config.dir, `.connect-${crypto.randomUUID()}.tmp`);
69
119
  try {
70
120
  const before = await readFile(file, 'utf8');
71
121
  const saved = JSON.parse(before);
72
122
  const savedIdentity = cloudConnection(saved, {});
123
+ if (JSON.stringify(savedIdentity.auth ?? null) !== JSON.stringify(connection.auth ?? null))
124
+ throw new ConfigError('OAuth connection changed during verification; retry without replacing its credential binding.');
73
125
  if (savedIdentity.workspaceId && savedIdentity.workspaceId !== workspaceId || savedIdentity.projectId && savedIdentity.projectId !== projectId)
74
126
  throw new ConfigError('This ledger is connected to another workspace or project; use a separate ledger to preserve its cursors and mapping.');
75
127
  if (saved.cloud?.origin && new URL(saved.cloud.origin).origin !== parsed.origin)
76
128
  throw new ConfigError('This ledger is connected to another origin; use a separate ledger to preserve its cursors and mapping.');
77
129
  saved.cloud = { ...saved.cloud, origin: parsed.origin, workspaceId };
130
+ if (auth) saved.cloud.auth = auth;
131
+ else delete saved.cloud.auth;
78
132
  if (ledgerIds !== undefined) saved.cloud.ledgerIds = ledgerIds;
79
133
  delete saved.cloud.token;
80
134
  saved.project = { ...saved.project, id: projectId };
81
135
  await writeFile(temporary, JSON.stringify(saved, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
82
136
  if (await readFile(file, 'utf8') !== before) throw new ConfigError('Connection configuration changed; retry.');
83
137
  await rename(temporary, file);
138
+ committed = true;
84
139
  } finally {
85
140
  await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; });
86
- await lock.close(); await unlink(lockPath);
141
+ await lock.release();
87
142
  }
88
- out(`Verified workspace ${workspaceId}, project ${projectId}. Saved connection metadata; keep the key in the environment. Run agentlinkops sync --dry-run to review uploads, or agentlinkops sync --pull-only to retrieve history.`);
143
+ out(auth
144
+ ? `Verified workspace ${workspaceId}, project ${projectId}. Saved OAuth credential reference; write permission remains unverified. Run agentlinkops sync --pull-only to retrieve history.`
145
+ : `Verified workspace ${workspaceId}, project ${projectId}. Saved connection metadata; keep the key in the environment. Run agentlinkops sync --dry-run to review uploads, or agentlinkops sync --pull-only to retrieve history.`);
89
146
  return 0;
147
+ } catch (error) {
148
+ // A failed connection must not strand a newly created local secret.
149
+ if (createdRef && !committed) await credentialStore.remove(createdRef, {issuer: auth.issuer, resource: auth.resource, clientId: auth.clientId}).catch(() => {});
150
+ throw error;
151
+ }
90
152
  }
package/cli/doctor.js CHANGED
@@ -19,6 +19,7 @@ import { loadConfig, setNoticeSink } from './config.js';
19
19
  import { readLedger } from './ledger.js';
20
20
  import { readReceipts } from './receipts.js';
21
21
  import { readState, writeState } from './state.js';
22
+ import { acquireSyncLock } from './sync-lock.js';
22
23
  import { createClient, CloudError } from './client.js';
23
24
  import { cloudConnection } from './connection.js';
24
25
  import { validatePublicUrl, parseLinks, transitionState, checkObservationKind } from '../src/verifier/index.js';
@@ -112,9 +113,9 @@ export async function probeCloud({ origin, fetchImpl, timeoutMs = 5000 }) {
112
113
  * uses. Scope is a server-side fact, so this read (a plain watch listing, metered as none) is
113
114
  * the only honest answer to "is this token good for anything here".
114
115
  */
115
- export async function probeToken({ origin, token, workspaceId, fetchImpl }) {
116
+ export async function probeToken({ origin, token, auth, workspaceId, projectId, fetchImpl }) {
116
117
  try {
117
- await createClient({ origin, token, workspaceId, fetchImpl }).listWatches({ limit: 1 });
118
+ await createClient({ origin, token, auth, workspaceId, projectId, fetchImpl }).listWatches({ limit: 1 });
118
119
  return { verified: true };
119
120
  } catch (error) {
120
121
  if (error instanceof CloudError) return { verified: false, code: error.code, status: error.status, scope: error.details?.scope ?? null };
@@ -214,15 +215,15 @@ export async function doctorMain(argv = [], { cwd = process.cwd(), out = console
214
215
  // Token: presence, shape, and — only when the cloud just proved reachable — whether the
215
216
  // cloud accepts it. The value itself is never printed, hashed or measured here.
216
217
  const token = connection.token || null;
217
- const source = connection.tokenSource ? `the environment (${connection.tokenSource})` : `${config.dirName}/config.json (cloud.token)`;
218
- if (!token) {
218
+ const source = connection.auth ? 'the private OAuth credential store' : connection.tokenSource ? `the environment (${connection.tokenSource})` : `${config.dirName}/config.json (cloud.token)`;
219
+ if (!token && !connection.auth) {
219
220
  if (origin) {
220
221
  checks.push({ status: 'fail', name: 'token', detail: 'no token is set; sync and receive need one',
221
222
  fix: 'run agentlinkops connect for the app API-key setup path; supply AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY in the environment' });
222
223
  } else {
223
224
  checks.push({ status: 'skip', name: 'token', detail: 'no token (and no cloud configured)' });
224
225
  }
225
- } else if (!TOKEN_SHAPE.test(token)) {
226
+ } else if (!connection.auth && !TOKEN_SHAPE.test(token)) {
226
227
  checks.push({ status: 'fail', name: 'token', detail: `set in ${source} but it does not look like an AgentLinkOps API key (lt_ plus 64 hex characters)`,
227
228
  fix: 'copy the full key without truncation or line breaks; the key itself is never printed here' });
228
229
  } else if (!origin) {
@@ -230,15 +231,20 @@ export async function doctorMain(argv = [], { cwd = process.cwd(), out = console
230
231
  } else {
231
232
  const cloudLine = checks.find(check => check.name === 'cloud');
232
233
  if (cloudLine?.status === 'ok') {
233
- const probed = await probeToken({ origin, token, workspaceId: config.cloud?.workspaceId, fetchImpl });
234
+ const probed = await probeToken({ ...connection, fetchImpl });
234
235
  if (probed.verified) {
235
236
  record.token = { ok: true };
236
237
  checks.push({ status: 'ok', name: 'token', detail: `set in ${source}; the cloud accepted it (watches:read works)` });
237
238
  } else if (probed.verified === false) {
238
239
  record.token = { ok: false, code: probed.code };
239
- const fix = probed.code === 'INSUFFICIENT_SCOPE' ? `issue a key carrying ${probed.scope ?? 'watches:read'}`
240
+ const fix = probed.code === 'INSUFFICIENT_SCOPE' ? connection.auth
241
+ ? `authorize ${probed.scope ?? 'watches:read'} through the configured OAuth client and workspace grant`
242
+ : `issue a key carrying ${probed.scope ?? 'watches:read'}`
240
243
  : probed.code === 'WORKSPACE_DENIED' ? `check cloud.workspaceId in ${config.dirName}/config.json against the workspace the key belongs to`
241
- : probed.code === 'UNAUTHORIZED' ? 'issue a fresh key (this one is invalid or expired) and update where it is set'
244
+ : probed.code === 'UNAUTHORIZED' ? connection.auth
245
+ ? 'reconnect with the configured OAuth issuer and public client; do not repeat an unresolved write'
246
+ : 'issue a fresh key (this one is invalid or expired) and update where it is set'
247
+ : probed.code === 'OAUTH_CREDENTIAL_STORAGE_UNSUPPORTED' ? 'use an API-key connection or OAuth in your MCP client until native credential custody is verified'
242
248
  : 'sync will fail the same way; act on the code above';
243
249
  const detail = probed.code === 'INSUFFICIENT_SCOPE' ? `the cloud accepted it but scope ${probed.scope ?? 'watches:read'} is missing`
244
250
  : probed.code === 'WORKSPACE_DENIED' ? 'the credential belongs to a different workspace'
@@ -287,12 +293,21 @@ export async function doctorMain(argv = [], { cwd = process.cwd(), out = console
287
293
  // of failing doctor twice.
288
294
  if (probed) {
289
295
  const cloudLine = checks.find(check => check.name === 'cloud');
296
+ let writer = null;
290
297
  try {
291
- const current = state ?? await readState(config.paths.state);
298
+ writer = await acquireSyncLock(config.dir, 'doctor');
299
+ // Diagnostics may await the network while sync commits. Merge only our record
300
+ // into fresh state under the same writer gate; the early snapshot is stale.
301
+ const current = await readState(config.paths.state);
292
302
  await writeState(config.paths.state, { ...current, doctor: record });
293
303
  if (cloudLine) cloudLine.detail += ' (recorded in state.json)';
294
304
  } catch (error) {
295
305
  if (cloudLine) cloudLine.detail += ` (result NOT recorded: ${error.message})`;
306
+ } finally {
307
+ if (writer) {
308
+ try { await writer.release(); }
309
+ catch (error) { if (cloudLine) cloudLine.detail += ` (writer release failed: ${error.message})`; }
310
+ }
296
311
  }
297
312
  }
298
313
 
@@ -15,7 +15,7 @@ Files must be new. CSV exports are limited to 20 MiB and include a receipt.
15
15
  Review column mapping before importing. The export does not prove links are live.`;
16
16
 
17
17
  // This factory also runs in Playwriter's CommonJS sandbox. Keep dependencies explicit.
18
- export function createGscBrowserExporter({ fs, path, createHash, parseCsv }) {
18
+ export function createGscBrowserExporter({ fs, path, createHash, parseCsv, platform = process.platform }) {
19
19
  const limit = 20 * 1024 * 1024;
20
20
  const fail = (code, message) => Object.assign(new Error(message), { code, exitCode: 2 });
21
21
  async function bounded(promise, milliseconds) {
@@ -82,7 +82,7 @@ export function createGscBrowserExporter({ fs, path, createHash, parseCsv }) {
82
82
  const report = new URL('https://search.google.com/search-console/links');
83
83
  report.searchParams.set('resource_id', property);
84
84
  report.searchParams.set('hl', 'en');
85
- let download, ownedOutput = false, ownedReceipt = false;
85
+ let download, outputHandle, receiptHandle, ownedOutput = false, ownedReceipt = false;
86
86
  const receive = item => { if (!download) download = item; };
87
87
  try {
88
88
  await page.goto(report.href, { waitUntil: 'domcontentloaded', timeout: 20000 });
@@ -190,14 +190,27 @@ export function createGscBrowserExporter({ fs, path, createHash, parseCsv }) {
190
190
  source_column: sourceHeader, target_mapping_required: true,
191
191
  sampled: true, current_link_status: 'unverified',
192
192
  };
193
- await fs.writeFile(outputPath, Buffer.concat(chunks), { flag: 'wx', mode: 0o600 }); ownedOutput = true;
194
- await fs.writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { flag: 'wx', mode: 0o600 }); ownedReceipt = true;
193
+ outputHandle = await fs.open(outputPath, 'wx', 0o600); ownedOutput = true;
194
+ receiptHandle = await fs.open(receiptPath, 'wx', 0o600); ownedReceipt = true;
195
+ for (const handle of [outputHandle, receiptHandle]) {
196
+ const info = await handle.stat();
197
+ if (!info.isFile() || (platform !== 'win32' && (info.mode & 0o7777) !== 0o600)) {
198
+ throw fail('private_storage_required', 'GSC exports require private files with mode 0600. Choose a filesystem that enforces private permissions.');
199
+ }
200
+ }
201
+ await outputHandle.writeFile(Buffer.concat(chunks));
202
+ await receiptHandle.writeFile(`${JSON.stringify(receipt, null, 2)}\n`);
203
+ await outputHandle.close(); outputHandle = undefined;
204
+ await receiptHandle.close(); receiptHandle = undefined;
195
205
  return receipt;
196
206
  } catch (error) {
197
207
  await download?.cancel?.().catch(() => {});
208
+ await outputHandle?.close().catch(() => {});
209
+ await receiptHandle?.close().catch(() => {});
198
210
  if (ownedOutput) await fs.unlink(outputPath).catch(() => {});
199
211
  if (ownedReceipt) await fs.unlink(receiptPath).catch(() => {});
200
212
  if (error.exitCode === 2) throw error;
213
+ if (error.code === 'EEXIST') throw fail('output_exists', 'The output or receipt file already exists. Choose a new path.');
201
214
  throw fail('browser_export_failed', 'GSC export failed. Check your browser session and retry.');
202
215
  } finally {
203
216
  page.off('download', receive);
@@ -206,7 +219,7 @@ export function createGscBrowserExporter({ fs, path, createHash, parseCsv }) {
206
219
  };
207
220
  }
208
221
 
209
- export const exportGscLinks = createGscBrowserExporter({ fs, path, createHash, parseCsv });
222
+ export const exportGscLinks = createGscBrowserExporter({ fs, path, createHash, parseCsv, platform: process.platform });
210
223
 
211
224
  export async function gscLinksMain(argv, { cwd = process.cwd(), out = console.log, execFileImpl = promisify(execFile) } = {}) {
212
225
  const args = parseArgs(argv);
@@ -221,7 +234,7 @@ export async function gscLinksMain(argv, { cwd = process.cwd(), out = console.lo
221
234
  const scratch = await fs.mkdtemp(path.join(tmpdir(), 'agentlinkops-gsc-'));
222
235
  try {
223
236
  // Pass source/config as a file argument, never interpolate a shell command.
224
- const script = `const CsvError = ${CsvError.toString()};\nconst parseCsv = ${parseCsv.toString()};\nconst exportLinks = (${createGscBrowserExporter.toString()})({fs:require('node:fs').promises,path:require('node:path'),createHash:require('node:crypto').createHash,parseCsv});\nconst config = ${JSON.stringify({ property: args.property, outputPath, kind: args.kind ?? 'latest' })};\nconst exportPage = await context.newPage();\ntry { const receipt = await exportLinks({...config,page:exportPage}); console.log('AGENTLINKOPS_GSC_RECEIPT '+JSON.stringify(receipt)); } catch(error) { console.log('AGENTLINKOPS_GSC_ERROR '+JSON.stringify({code:error.code||'browser_export_failed',message:error.message})); } finally { await exportPage.close(); }\n`;
237
+ const script = `const CsvError = ${CsvError.toString()};\nconst parseCsv = ${parseCsv.toString()};\nconst exportLinks = (${createGscBrowserExporter.toString()})({fs:require('node:fs').promises,path:require('node:path'),createHash:require('node:crypto').createHash,parseCsv,platform:require('node:os').platform()});\nconst config = ${JSON.stringify({ property: args.property, outputPath, kind: args.kind ?? 'latest' })};\nconst exportPage = await context.newPage();\ntry { const receipt = await exportLinks({...config,page:exportPage}); console.log('AGENTLINKOPS_GSC_RECEIPT '+JSON.stringify(receipt)); } catch(error) { console.log('AGENTLINKOPS_GSC_ERROR '+JSON.stringify({code:error.code||'browser_export_failed',message:error.message})); } finally { await exportPage.close(); }\n`;
225
238
  const scriptPath = path.join(scratch, 'export.js');
226
239
  await fs.writeFile(scriptPath, script, { mode: 0o600 });
227
240
  let stdout;
package/cli/ledger.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { acquireSyncLock } from './sync-lock.js';
1
2
  // The ledger: intent, human-owned, one JSON object per line.
2
3
  //
3
4
  // Reading is deliberately forgiving of everything except silence. A line that does not parse,
@@ -5,7 +6,7 @@
5
6
  // set — never dropped quietly. A tool that discards a customer's row and carries on produces a
6
7
  // number that is wrong in a way they discover months later.
7
8
  import { readFile, writeFile, mkdir, rename, open, unlink } from 'node:fs/promises';
8
- import { dirname, join } from 'node:path';
9
+ import { basename, dirname, join } from 'node:path';
9
10
  import {lifecycleDeal,emptyDeal} from '../shared/lifecycle-contract.js';
10
11
 
11
12
  export const INTENTS = Object.freeze(['wanted', 'expected', 'retired']);
@@ -189,11 +190,7 @@ export async function writeLedger(path, entries) {
189
190
 
190
191
  /** Read, validate and replace under one lock. A malformed row prevents every mutation. */
191
192
  export async function mutateLedger(path, update) {
192
- const lockPath = `${path}.lock`;
193
- const lock = await open(lockPath, 'wx').catch(error => {
194
- if (error.code === 'EEXIST') throw new Error('Ledger is being updated by another process.');
195
- throw error;
196
- });
193
+ const lock = await acquireSyncLock(dirname(path), 'ledger-update', {markerName: `${basename(path)}.lock`});
197
194
  try {
198
195
  const ledger = await readLedger(path);
199
196
  if (ledger.missing) throw new Error('Ledger does not exist.');
@@ -201,7 +198,7 @@ export async function mutateLedger(path, update) {
201
198
  const result = await update(ledger.entries);
202
199
  await writeLedger(path, result);
203
200
  return result;
204
- } finally { await lock.close(); await unlink(lockPath); }
201
+ } finally { await lock.release(); }
205
202
  }
206
203
 
207
204
  /**
package/cli/lifecycle.js CHANGED
@@ -13,7 +13,7 @@ agentlinkops lifecycle report [--from YYYY-MM-DD] [--to YYYY-MM-DD]
13
13
  Uses the connected project. Deal costs are integer currency minor units. Null clears a field; costMinor and currency must be set or cleared together. Clear removes deal metadata, not the watch. Renew retains due events without sending a message. --decimal-cost reads cost_amount as an exact decimal string and cost_currency using the supported currency scale. sync --lifecycle pulls a deal mirror without editing the ledger.`;
14
14
  const fail=message=>{throw new ConfigError(message);};
15
15
  const integer=(value,name)=>{if(typeof value!=='string'||!/^\d+$/.test(value)||!Number.isSafeInteger(Number(value)))fail(`${name} requires a nonnegative integer`);return Number(value);};
16
- export async function lifecycleMain(args,{cwd=process.cwd(),env=process.env,out=console.log,fetchImpl=globalThis.fetch}={}){
16
+ export async function lifecycleMain(args,{cwd=process.cwd(),env=process.env,out=console.log,err=console.error,fetchImpl=globalThis.fetch}={}){
17
17
  if(args.help){out(LIFECYCLE_CLI_HELP);return 0;}
18
18
  const action=args._?.[1],allowed={get:[],update:['revision','deal','decimal-cost'],clear:['revision'],renew:['limit'],report:['from','to']}[action];
19
19
  if(!allowed||args.tag?.length||Object.keys(args).some(key=>!['_','tag',...allowed].includes(key)))fail(LIFECYCLE_CLI_HELP);
@@ -36,5 +36,8 @@ export async function lifecycleMain(args,{cwd=process.cwd(),env=process.env,out=
36
36
  if(action==='renew'){name='record_lifecycle_renewals';schema=C.lifecycleRenewInput;output=C.lifecycleRenewOutput;if(args.limit!==undefined)input.limit=integer(args.limit,'--limit');}
37
37
  if(action==='report'){name='get_lifecycle_report';schema=C.lifecycleReportInput;output=C.lifecycleReportOutput;for(const key of ['from','to'])if(args[key]!==undefined)input[key]=args[key];}
38
38
  const parsed=schema.safeParse(input);if(!parsed.success)fail('Invalid lifecycle dates, fields or bounds');
39
- const result=await createClient({...connection,fetchImpl}).callCommand(name,parsed.data),checked=output.safeParse(result);if(!checked.success)fail('Invalid lifecycle service response');out(JSON.stringify(checked.data,null,2));return 0;
39
+ let result;
40
+ try{result=await createClient({...connection,fetchImpl}).callCommand(name,parsed.data);}
41
+ catch(error){if(!error.publicError)throw error;err(JSON.stringify({error:error.publicError}));return 2;}
42
+ const checked=output.safeParse(result);if(!checked.success)fail('Invalid lifecycle service response');out(JSON.stringify(checked.data,null,2));return 0;
40
43
  }
@@ -1,8 +1,9 @@
1
+ import { acquireSyncLock } from './sync-lock.js';
1
2
  // Portable, accountless first result and explicit repository adoption. No hosted client.
2
3
  import { createHash, randomUUID } from 'node:crypto';
3
4
  import { z } from 'zod';
4
5
  import { readFile, writeFile, mkdir, open, rename, unlink } from 'node:fs/promises';
5
- import { dirname, join } from 'node:path';
6
+ import { basename, dirname, join } from 'node:path';
6
7
  import { matchesTarget } from '../src/verifier/url.js';
7
8
  import { validatePublicUrl } from '../src/verifier/index.js';
8
9
  import { runCheck } from './check.js';
@@ -124,8 +125,7 @@ export async function adoptLocalResult(config, supplied, { intent = 'wanted', af
124
125
  if (!['wanted', 'expected'].includes(intent)) throw new Error('adoption intent must be wanted or expected');
125
126
  const result = validateLocalResult(supplied);
126
127
  if (new Set([config.paths.ledger, config.paths.observations, config.paths.state]).size !== 3) throw new Error('adoption destinations must be distinct');
127
- const lockPath = `${config.paths.ledger}.lock`;
128
- const lock = await open(lockPath, 'wx');
128
+ const lock = await acquireSyncLock(dirname(config.paths.ledger), 'adopt-result', {markerName: `${basename(config.paths.ledger)}.lock`});
129
129
  const journalPath = join(config.dir, 'adopt-result-transaction.json');
130
130
  try {
131
131
  const pending = await textAt(journalPath);
@@ -157,5 +157,5 @@ export async function adoptLocalResult(config, supplied, { intent = 'wanted', af
157
157
  await writeFile(journalPath, JSON.stringify({ version: 1, parts }), { flag: 'wx' });
158
158
  await finishAdoption(journalPath, { version: 1, parts }, config, afterWrite);
159
159
  return { result_id: result.result_id, ledger_id: entry.id, created: !matches[0], observation_added: true };
160
- } finally { await lock.close(); await unlink(lockPath); }
160
+ } finally { await lock.release(); }
161
161
  }