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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -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/ledger.js +4 -7
- package/cli/lifecycle.js +5 -2
- package/cli/local-result.js +4 -4
- package/cli/main.js +42 -27
- 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 +17 -3
- 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
package/cli/main.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { acquireSyncLock } from './sync-lock.js';
|
|
1
2
|
import {lifecycleMain} from './lifecycle.js';
|
|
2
3
|
import {admissionMain} from './admission.js';
|
|
3
4
|
import { commandsMain } from './commands.js';
|
|
@@ -77,20 +78,20 @@ const USAGE = `agentlinkops — a backlink ledger that lives in your repository
|
|
|
77
78
|
.agentlinkops/citations/; live engines need
|
|
78
79
|
engine-specific setup; the mock engine makes no paid calls
|
|
79
80
|
agentlinkops receive --body FILE --headers FILE signed notification; authenticated pull
|
|
80
|
-
agentlinkops connect --workspace ID --project-id ID [--origin URL] [--selection FILE]
|
|
81
|
+
agentlinkops connect --workspace ID --project-id ID [--origin URL] [--selection FILE] [--oauth --issuer URL --client-id ID [--credential-ref REF]]
|
|
81
82
|
agentlinkops tools [TOOLSET] [--json] [--refresh] cloud commands by toolset (offline snapshot;
|
|
82
83
|
--refresh reads the live catalog)
|
|
83
84
|
agentlinkops describe NAME [--examples] [--output-schema] [--json] one command's exact schema
|
|
84
85
|
agentlinkops call NAME [--args JSON | --file FILE | --set PATH=VALUE ...] [--dry-run] [-y]
|
|
85
86
|
run a cloud command (retired names still resolve)
|
|
86
87
|
agentlinkops agent setup [--client ID ...] [--scope project|user] [--origin URL] [--dry-run]
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
core connection/reference and the right MCP view;
|
|
89
|
+
optional --skill ID ... / --recipe ID ... / --all-skills;
|
|
90
|
+
stores no credential; preserves customer instructions
|
|
90
91
|
agentlinkops agent status what is installed where
|
|
91
|
-
agentlinkops agent remove [--scope project|user] [--apply] [--json]
|
|
92
|
+
agentlinkops agent remove [--skill ID ...] [--recipe ID ...] [--scope project|user] [--apply] [--json]
|
|
92
93
|
agentlinkops agent recover [--scope project|user] [--apply] [--json]
|
|
93
|
-
agentlinkops sync [--push-only] [--pull-only] [--dry-run] [--include-wanted] [--lifecycle]
|
|
94
|
+
agentlinkops sync [--push-only] [--pull-only] [--dry-run] [--include-wanted] [--lifecycle] [--recover-cursors]
|
|
94
95
|
AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY
|
|
95
96
|
agentlinkops check [--filter TEXT] [--all] [--json] [--fail-on-unknown]
|
|
96
97
|
agentlinkops doctor verify ledger, verifier, cloud reachability, token
|
|
@@ -133,7 +134,7 @@ function reportProblems(problems, what, out) {
|
|
|
133
134
|
if (problems.length > 20) out(` … and ${problems.length - 20} more`);
|
|
134
135
|
}
|
|
135
136
|
|
|
136
|
-
export async function main(argv = process.argv.slice(2), { cwd = process.cwd(), out = console.log, err = console.error, env = process.env, fetchImpl = globalThis.fetch } = {}) {
|
|
137
|
+
export async function main(argv = process.argv.slice(2), { cwd = process.cwd(), out = console.log, err = console.error, env = process.env, fetchImpl = globalThis.fetch, afterHistoryPersisted, afterStatePersisted } = {}) {
|
|
137
138
|
if (argv[0] === 'gsc-links') return await gscLinksMain(argv, { cwd, out });
|
|
138
139
|
// Citation watches keep their own files under .agentlinkops/citations/ and never
|
|
139
140
|
// touch the link ledger, so they dispatch before its requirement like context does.
|
|
@@ -147,7 +148,9 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
|
|
|
147
148
|
|
|
148
149
|
let syncLock = null;
|
|
149
150
|
try {
|
|
150
|
-
if (command
|
|
151
|
+
if (args['recover-cursors'] !== undefined && (command !== 'sync' || args['recover-cursors'] !== true || args['pull-only'] !== true || args['push-only']))
|
|
152
|
+
throw new ConfigError('Cursor recovery requires agentlinkops sync --pull-only --recover-cursors; it performs no remote writes.');
|
|
153
|
+
if (command === 'lifecycle') return await lifecycleMain(args,{cwd,env,fetchImpl,out,err});
|
|
151
154
|
if (command === 'admission') { const config=await loadConfig({cwd}); return await admissionMain(argv.slice(1),{client:createClient({...cloudConnection(config,env),fetchImpl}),out,err}); }
|
|
152
155
|
if (command === 'disavow') return await disavowMain(args,{cwd,env,fetchImpl,out});
|
|
153
156
|
if (command === 'index') return await indexMain(args,{cwd,env,fetchImpl,out});
|
|
@@ -156,8 +159,8 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
|
|
|
156
159
|
if (command === 'skill') return await skillMain(argv, { out });
|
|
157
160
|
if (command === 'agent') return await agentMain(argv, { cwd, out, err });
|
|
158
161
|
if (command === 'connect') {
|
|
159
|
-
if (args._.length !== 1 || args.tag.length || Object.keys(args).some(key => !['_', 'tag', 'origin', 'workspace', 'project-id', 'selection'].includes(key))) throw new ConfigError('connect --workspace ID --project-id ID [--origin URL] [--selection FILE]');
|
|
160
|
-
return await connectMain(args, { cwd, out });
|
|
162
|
+
if (args._.length !== 1 || args.tag.length || Object.keys(args).some(key => !['_', 'tag', 'origin', 'workspace', 'project-id', 'selection', 'oauth', 'issuer', 'client-id', 'credential-ref'].includes(key))) throw new ConfigError('connect --workspace ID --project-id ID [--origin URL] [--selection FILE] [--oauth --issuer URL --client-id ID [--credential-ref REF]]');
|
|
163
|
+
return await connectMain(args, { cwd, out, env, fetchImpl });
|
|
161
164
|
}
|
|
162
165
|
if (command === 'check' && (args.source !== undefined || args.target !== undefined)) {
|
|
163
166
|
if (typeof args.source !== 'string' || typeof args.target !== 'string') throw new ConfigError('check requires both --source URL and --target URL');
|
|
@@ -329,9 +332,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
|
|
|
329
332
|
|
|
330
333
|
const config = await loadConfig({ cwd });
|
|
331
334
|
if (['sync','receive','check','compact','adopt-result'].includes(command) && !(command === 'sync' && args['dry-run'])) {
|
|
332
|
-
|
|
333
|
-
try { syncLock = { handle: await open(lockPath, 'wx'), path: lockPath }; }
|
|
334
|
-
catch (error) { if (error.code === 'ENOENT') throw new ConfigError('no ledger here — run `agentlinkops init` first'); if (error.code === 'EEXIST') throw new ConfigError('sync writer already locked; retry delivery after it finishes'); throw error; }
|
|
335
|
+
syncLock = await acquireSyncLock(config.dir, command);
|
|
335
336
|
}
|
|
336
337
|
if (command === 'adopt-result') {
|
|
337
338
|
if (args._.length !== 2 || Object.keys(args).some(key => !['_', 'intent', 'tag'].includes(key)) || args.tag.length) throw new ConfigError('adopt-result FILE [--intent wanted|expected]');
|
|
@@ -506,7 +507,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
|
|
|
506
507
|
for (const change of found.slice(0, 100)) out(` ${change.at.slice(0, 10)} ${change.from} -> ${change.to} ${change.id}`);
|
|
507
508
|
return 0;
|
|
508
509
|
}
|
|
509
|
-
const groups = { appeared: 'appeared', lost: 'LOST', cannot_say: 'cannot say', never_checked: 'never checked' };
|
|
510
|
+
const groups = { appeared: 'appeared', lost: 'LOST', suspected_missing: 'suspected missing', cannot_say: 'cannot say', never_checked: 'never checked' };
|
|
510
511
|
for (const [kind, label] of Object.entries(groups)) {
|
|
511
512
|
const rows = found.filter(item => item.kind === kind);
|
|
512
513
|
if (!rows.length) continue;
|
|
@@ -524,7 +525,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
|
|
|
524
525
|
if (command === 'receive') {
|
|
525
526
|
if (!args.body || !args.headers) throw new ConfigError('receive --body RAW_BODY_FILE --headers HEADERS_JSON_FILE');
|
|
526
527
|
verifyDelivery({ body: await readFile(String(args.body), 'utf8'), headers: JSON.parse(await readFile(String(args.headers), 'utf8')),
|
|
527
|
-
secret: readEnv(
|
|
528
|
+
secret: readEnv(env, 'WEBHOOK_SECRET').value, workspaceId: config.cloud?.workspaceId });
|
|
528
529
|
args['pull-only'] = true;
|
|
529
530
|
args['push-only'] = false;
|
|
530
531
|
}
|
|
@@ -533,11 +534,11 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
|
|
|
533
534
|
if(args.lifecycle&&args['push-only'])throw new ConfigError('--lifecycle requires a pull; omit --push-only.');
|
|
534
535
|
const selection = { includeWanted: args['include-wanted'] === true, ledgerIds: config.cloud?.ledgerIds ?? null };
|
|
535
536
|
if (args['dry-run']) { out(JSON.stringify({ network: false, rows: args['pull-only'] ? [] : syncPlan(ledger.entries, state, selection) }, null, 2)); return 0; }
|
|
536
|
-
const connection = cloudConnection(config);
|
|
537
|
+
const connection = cloudConnection(config, env);
|
|
537
538
|
if (!connection.origin) { err('no cloud origin — run agentlinkops connect'); return 2; }
|
|
538
|
-
if (!connection.token) { err(keySetup(connection.origin)); return 2; }
|
|
539
|
+
if (!connection.token && !connection.auth) { err(keySetup(connection.origin)); return 2; }
|
|
539
540
|
if (!connection.projectId) { err('no project id — run agentlinkops connect'); return 2; }
|
|
540
|
-
const client = createClient(connection);
|
|
541
|
+
const client = createClient({ ...connection, fetchImpl });
|
|
541
542
|
let next = { ...state };
|
|
542
543
|
let pushFailed = false;
|
|
543
544
|
if (!args['pull-only']) {
|
|
@@ -549,28 +550,38 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
|
|
|
549
550
|
pushFailed = pushed.failed.length > 0;
|
|
550
551
|
}
|
|
551
552
|
if (!args['push-only']) {
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
553
|
+
const snapshots = [];
|
|
554
|
+
const recovery = {
|
|
555
|
+
recoverInvalidCursor: args['recover-cursors'] === true,
|
|
556
|
+
onSnapshot: async (snapshot, feed) => {
|
|
557
|
+
const path = join(config.dir, `snapshot-${feed}-${Date.now()}-${crypto.randomUUID()}.json`);
|
|
558
|
+
await writeFile(path, `${JSON.stringify(snapshot, null, 1)}\n`, {encoding: 'utf8', flag: 'wx', mode: 0o600});
|
|
559
|
+
snapshots.push({feed, path});
|
|
557
560
|
},
|
|
561
|
+
};
|
|
562
|
+
const pulled = await pullEvents(client, next, {
|
|
563
|
+
...recovery,
|
|
558
564
|
});
|
|
559
565
|
// Two feeds, two cursors. They are pulled separately and written separately, so an
|
|
560
566
|
// expiry on one cannot move the other.
|
|
561
|
-
const targets = await pullTargetEvents(client, next);
|
|
567
|
+
const targets = await pullTargetEvents(client, next, recovery);
|
|
562
568
|
const index = await watchIndex(client, next);
|
|
563
569
|
if(args.lifecycle)next.lifecycle=await pullLifecycleMirrors(client,next,{projectId:connection.projectId,index,ledgerIds:selection.ledgerIds??ledger.entries.map(entry=>entry.id)});
|
|
564
570
|
const { fresh, duplicates } = await persistPulledHistory(config, pulled.events, targets.events, index);
|
|
571
|
+
await afterHistoryPersisted?.();
|
|
565
572
|
next.cursors = { ...(next.cursors ?? {}), events: pulled.cursor, target_events: targets.cursor };
|
|
566
573
|
next.gaps = [...(next.gaps ?? []), ...pulled.gaps, ...targets.gaps];
|
|
567
574
|
out(`pulled ${pulled.events.length} event(s) over ${pulled.pages} page(s), ${fresh.length} observation(s)${duplicates.length ? `, ${duplicates.length} already held` : ''}`);
|
|
568
575
|
if (targets.events.length) out(`pulled ${targets.events.length} target event(s) over ${targets.pages} page(s)`);
|
|
569
576
|
// A gap is stated, never inferred. An empty feed means nothing happened; an expired
|
|
570
577
|
// cursor means something happened and is gone.
|
|
571
|
-
for (const gap of [...pulled.gaps, ...targets.gaps])
|
|
578
|
+
for (const gap of [...pulled.gaps, ...targets.gaps]) {
|
|
579
|
+
const snapshot = snapshots.find(item => item.feed === gap.feed);
|
|
580
|
+
out(` RESYNC ${gap.at}: ${gap.reason}; ${snapshot ? `snapshot saved to ${snapshot.path}` : 'current-state snapshot applied'}`);
|
|
581
|
+
}
|
|
572
582
|
}
|
|
573
583
|
await writeState(config.paths.state, next);
|
|
584
|
+
await afterStatePersisted?.();
|
|
574
585
|
return pushFailed ? 2 : 0;
|
|
575
586
|
}
|
|
576
587
|
|
|
@@ -595,7 +606,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
|
|
|
595
606
|
if (args.json) { out(rows.map(row => JSON.stringify(row)).join('\n')); return summary.exitCode; }
|
|
596
607
|
reportProblems(ledger.problems, 'ledger line(s)', out);
|
|
597
608
|
for (const { entry, row } of summary.appeared) out(`APPEARED ${entry.id} ${entry.source} (${row.occurrences} occurrence(s))`);
|
|
598
|
-
for (const { entry, row } of summary.failures) out(`
|
|
609
|
+
for (const { entry, row } of summary.failures) out(`ABSENT ${entry.id} ${entry.source} [${row.reason}]`);
|
|
599
610
|
out(`\n${JSON.stringify(summary.counts)}`);
|
|
600
611
|
out(`${plural(changed.length, 'observation')} recorded, ${repeated.length} repeated an answer already on file`);
|
|
601
612
|
if (summary.counts.unknown) {
|
|
@@ -609,9 +620,13 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
|
|
|
609
620
|
return 2;
|
|
610
621
|
} catch (error) {
|
|
611
622
|
if (error instanceof ConfigError) { err(error.message); return 2; }
|
|
623
|
+
if (command === 'sync' && args['recover-cursors'] === true && error instanceof CloudError) {
|
|
624
|
+
err(JSON.stringify({error: {code: error.code, status: error.status, next: 'Keep this project ledger and its history. Resolve authorized read access for the configured project, then retry agentlinkops sync --pull-only --recover-cursors. Do not broaden grants or replay remote writes.'}}));
|
|
625
|
+
return 2;
|
|
626
|
+
}
|
|
612
627
|
err(`agentlinkops: ${error?.message ?? error}`);
|
|
613
628
|
return 2;
|
|
614
629
|
} finally {
|
|
615
|
-
if (syncLock)
|
|
630
|
+
if (syncLock) await syncLock.release();
|
|
616
631
|
}
|
|
617
632
|
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import {CloudError} from './cloud-error.js';
|
|
2
|
+
import {createOAuthCredentialStore} from './oauth-credentials.js';
|
|
3
|
+
import {discoverOAuth, refreshOAuthTokens} from './oauth-flow.js';
|
|
4
|
+
import {restResponseContract} from '../shared/response-contract.js';
|
|
5
|
+
import {SHAPES} from '../src/response-shaping.js';
|
|
6
|
+
|
|
7
|
+
const sleepDefault = ms => new Promise(done => setTimeout(done, ms));
|
|
8
|
+
const fail = (code, status = 400) => { throw new CloudError(code, status); };
|
|
9
|
+
const ERROR_BYTES = 65536;
|
|
10
|
+
const sensitive = (text, token) => typeof text === 'string' && (token && text.includes(token)
|
|
11
|
+
|| /\blt_[a-f0-9]{64}\b|\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b/.test(text));
|
|
12
|
+
function publicError(value, token, depth = 0) {
|
|
13
|
+
if (depth > 8) return '[redacted]';
|
|
14
|
+
if (typeof value === 'string') return sensitive(value, token) ? '[redacted]' : value;
|
|
15
|
+
if (Array.isArray(value)) return value.slice(0, 100).map(item => publicError(item, token, depth + 1));
|
|
16
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).slice(0, 100)
|
|
17
|
+
.filter(([key]) => !/token|authorization|password|secret|cookie|session/i.test(key))
|
|
18
|
+
.map(([key, item]) => [key, publicError(item, token, depth + 1)]));
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
async function boundedError(response) {
|
|
22
|
+
if (!response.body?.getReader) return null;
|
|
23
|
+
const reader = response.body.getReader(), chunks = []; let size = 0;
|
|
24
|
+
try {
|
|
25
|
+
while (true) {
|
|
26
|
+
const {value, done} = await reader.read(); if (done) break;
|
|
27
|
+
size += value.byteLength;
|
|
28
|
+
if (size > ERROR_BYTES) { await reader.cancel().catch(() => {}); return null; }
|
|
29
|
+
chunks.push(Buffer.from(value));
|
|
30
|
+
}
|
|
31
|
+
return JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(Buffer.concat(chunks, size)));
|
|
32
|
+
} catch { return null; } finally { reader.releaseLock(); }
|
|
33
|
+
}
|
|
34
|
+
const statusOf = error => (Number.isInteger(error.status) && error.status >= 400 && error.status <= 599 ? error.status : null) ?? ({INSUFFICIENT_SCOPE: 403, AGENT_APPROVAL_REQUIRED: 403,
|
|
35
|
+
GRANT_REVOKED: 401, UNAUTHORIZED: 401, CURSOR_EXPIRED: 410, RATE_LIMITED: 429,
|
|
36
|
+
QUOTA_EXCEEDED: 429, SERVICE_UNAVAILABLE: 503}[error.code]) ?? 400;
|
|
37
|
+
function payloadOf(result) {
|
|
38
|
+
if (result?.structuredContent && typeof result.structuredContent === 'object') return result.structuredContent;
|
|
39
|
+
try { return JSON.parse(result?.content?.find(item => item.type === 'text')?.text); }
|
|
40
|
+
catch { fail('INVALID_RESPONSE', 502); }
|
|
41
|
+
}
|
|
42
|
+
function normalized(result, token) {
|
|
43
|
+
const data = payloadOf(result);
|
|
44
|
+
if (result?.isError) {
|
|
45
|
+
const error = publicError(data?.error, token);
|
|
46
|
+
if (!error || typeof error.code !== 'string' || !/^[A-Z][A-Z0-9_]{1,100}$/.test(error.code)) fail('INVALID_RESPONSE', 502);
|
|
47
|
+
throw new CloudError(error.code, statusOf(error), error.details ?? null, error.message ?? null,
|
|
48
|
+
error, {retryAfter: error.retryAfter ?? publicError(result.retryAfter, token) ?? null,
|
|
49
|
+
requestId: error.requestId ?? publicError(result.requestId, token) ?? null});
|
|
50
|
+
}
|
|
51
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) fail('INVALID_RESPONSE', 502);
|
|
52
|
+
return data;
|
|
53
|
+
}
|
|
54
|
+
function retryDelay(error, attempt, now) {
|
|
55
|
+
if (!(error instanceof CloudError) || ![0, 429, 503].includes(error.status)) return null;
|
|
56
|
+
if (error.status === 0 && !['CLOUD_NETWORK_ERROR', 'CLOUD_TIMEOUT'].includes(error.code)) return null;
|
|
57
|
+
const value = error.retryAfter;
|
|
58
|
+
if (value == null || value === '') return Math.min(5000, 250 * 2 ** (attempt - 1));
|
|
59
|
+
const delay = /^\d+$/.test(String(value).trim()) ? Number(value) * 1000 : Date.parse(value) - now();
|
|
60
|
+
return Number.isFinite(delay) && delay >= 0 && delay <= 5000 ? delay : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function sdkClient({url, token, workspaceId, fetchImpl}) {
|
|
64
|
+
const {Client, StreamableHTTPClientTransport} = await import('@modelcontextprotocol/client');
|
|
65
|
+
const client = new Client({name: 'agentlinkops-cli', version: '0.6.10'});
|
|
66
|
+
const destination = new URL(url);
|
|
67
|
+
const transport = new StreamableHTTPClientTransport(destination, {
|
|
68
|
+
authProvider: {token: async () => token},
|
|
69
|
+
fetch: async (input, init = {}) => {
|
|
70
|
+
const target = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
|
|
71
|
+
if (target.href !== destination.href) fail('OAUTH_TOKEN_DESTINATION_MISMATCH');
|
|
72
|
+
const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
73
|
+
if (workspaceId) headers.set('X-Workspace-ID', workspaceId);
|
|
74
|
+
headers.set('User-Agent', 'agentlinkops-cli/0.6.10');
|
|
75
|
+
const response = await fetchImpl(input, {...init, headers, redirect: 'error', signal: AbortSignal.timeout(30000)});
|
|
76
|
+
if (response.redirected || response.status >= 300 && response.status < 400
|
|
77
|
+
|| response.url && response.url !== destination.href) fail('OAUTH_TOKEN_DESTINATION_MISMATCH');
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
const data = await boundedError(response), error = publicError(data?.error, token);
|
|
80
|
+
const code = typeof error?.code === 'string' && /^[A-Z][A-Z0-9_]{1,100}$/.test(error.code)
|
|
81
|
+
? error.code : `HTTP_${response.status}`;
|
|
82
|
+
throw new CloudError(code, response.status, error?.details ?? null,
|
|
83
|
+
error?.message ?? null, error ?? null,
|
|
84
|
+
{retryAfter: publicError(response.headers.get('Retry-After'), token),
|
|
85
|
+
requestId: error?.requestId ?? publicError(response.headers.get('X-Request-ID'), token)});
|
|
86
|
+
}
|
|
87
|
+
return response;
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
try { await client.connect(transport, {timeout: 30000}); return client; }
|
|
91
|
+
catch (error) { await client.close().catch(() => {}); throw error; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createMcpClient({origin, auth, workspaceId, projectId, credentialStore,
|
|
95
|
+
createSdkClient = sdkClient, fetchImpl = globalThis.fetch, sleepImpl = sleepDefault, now = Date.now, resolveHost} = {}) {
|
|
96
|
+
let base, issuer;
|
|
97
|
+
try { base = new URL(origin); issuer = new URL(auth?.issuer); } catch { fail('OAUTH_CONFIG_INVALID'); }
|
|
98
|
+
if (base.protocol !== 'https:' || base.origin !== origin || issuer.protocol !== 'https:' || issuer.origin !== auth?.issuer
|
|
99
|
+
|| auth?.kind !== 'oauth' || auth.resource !== `${origin}/mcp` || typeof auth.clientId !== 'string'
|
|
100
|
+
|| !auth.clientId || auth.clientId.length > 2048 || /[\s\x00-\x1f\x7f]/.test(auth.clientId)
|
|
101
|
+
|| !/^oauth_[a-f0-9]{32}$/.test(auth.credentialRef ?? '')) fail('OAUTH_CONFIG_INVALID');
|
|
102
|
+
const binding = {issuer: auth.issuer, resource: auth.resource, clientId: auth.clientId};
|
|
103
|
+
const store = credentialStore ?? createOAuthCredentialStore();
|
|
104
|
+
async function currentToken() {
|
|
105
|
+
try {
|
|
106
|
+
await store.read(auth.credentialRef, binding);
|
|
107
|
+
return await store.withCredential(auth.credentialRef, binding, async record => {
|
|
108
|
+
if (record.tokens.expiresAt > now() + 30000) return {value: record.tokens.accessToken};
|
|
109
|
+
const plan = await discoverOAuth({origin, ...binding, scopes: record.tokens.scope.split(' '), fetchImpl, resolveHost});
|
|
110
|
+
const tokens = await refreshOAuthTokens({plan, tokens: record.tokens, fetchImpl, now});
|
|
111
|
+
return {tokens, value: tokens.accessToken};
|
|
112
|
+
});
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error instanceof CloudError) throw error;
|
|
115
|
+
const code = typeof error?.code === 'string' && /^[A-Z][A-Z0-9_]{1,100}$/.test(error.code) ? error.code : 'OAUTH_CREDENTIAL_UNAVAILABLE';
|
|
116
|
+
throw new CloudError(code, 401, null, 'OAuth credentials could not be used. Reconnect through the configured issuer; do not repeat an unresolved write.');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function perform(operation, {safeRead = false, all = false} = {}) {
|
|
120
|
+
for (let attempt = 1; attempt <= (safeRead ? 3 : 1); attempt++) {
|
|
121
|
+
const token = await currentToken();
|
|
122
|
+
let client;
|
|
123
|
+
try {
|
|
124
|
+
client = await createSdkClient({url: all ? `${origin}/mcp/all` : auth.resource, token, workspaceId, fetchImpl});
|
|
125
|
+
return await operation(client, token);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
const status = Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599 ? error.status : null;
|
|
128
|
+
const failure = error instanceof CloudError ? error : status !== null ? new CloudError(`HTTP_${status}`, status,
|
|
129
|
+
null, 'The cloud request was refused.', null, {retryAfter: error.retryAfter ?? null, requestId: error.requestId ?? null}) : new CloudError(
|
|
130
|
+
['AbortError', 'TimeoutError'].includes(error?.name) ? 'CLOUD_TIMEOUT' : 'CLOUD_NETWORK_ERROR', 0,
|
|
131
|
+
{attempts: attempt}, safeRead ? 'The cloud read failed. Check the connection before retrying.'
|
|
132
|
+
: 'The cloud request did not return a conclusive result. Check its outcome before sending it again.');
|
|
133
|
+
const delay = safeRead && attempt < 3 ? retryDelay(failure, attempt, now) : null;
|
|
134
|
+
if (delay === null) throw failure;
|
|
135
|
+
await sleepImpl(delay);
|
|
136
|
+
} finally { await client?.close().catch(() => {}); }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const detailed = (name, args) => Object.hasOwn(SHAPES, name) ? {format: 'detailed', ...args} : args;
|
|
140
|
+
const scoped = (name, args) => detailed(name, {...args, ...(projectId ? {projectId} : {})});
|
|
141
|
+
const tool = (name, args, safeRead = false, path = null, method = 'GET') => perform(async (client, token) => {
|
|
142
|
+
const data = normalized(await client.callTool({name, arguments: args}, {timeout: 30000}), token);
|
|
143
|
+
if (!path) return data;
|
|
144
|
+
const parsed = restResponseContract(path, method).safeParse(data);
|
|
145
|
+
if (!parsed.success) fail('INVALID_RESPONSE', 502);
|
|
146
|
+
return parsed.data;
|
|
147
|
+
}, {safeRead});
|
|
148
|
+
return {
|
|
149
|
+
listCommands: () => perform(async (client, token) => {
|
|
150
|
+
const items = []; let cursor = '';
|
|
151
|
+
const visited = new Set([cursor]);
|
|
152
|
+
do {
|
|
153
|
+
// An explicit cursor selects the SDK's per-page path. A missing cursor
|
|
154
|
+
// auto-aggregates and can hide a repeated-page stop from our own guards.
|
|
155
|
+
const page = await client.listTools({cursor});
|
|
156
|
+
if (!Array.isArray(page?.tools)) fail('INVALID_RESPONSE', 502);
|
|
157
|
+
items.push(...page.tools);
|
|
158
|
+
if (items.length > 1000) fail('INVALID_RESPONSE', 502);
|
|
159
|
+
if (page.nextCursor === undefined) break;
|
|
160
|
+
if (typeof page.nextCursor !== 'string' || !page.nextCursor
|
|
161
|
+
|| visited.has(page.nextCursor) || visited.size >= 100) fail('INVALID_RESPONSE', 502);
|
|
162
|
+
cursor = page.nextCursor; visited.add(cursor);
|
|
163
|
+
} while (true);
|
|
164
|
+
// Meta tools are registered only on the default view, not the flat listing.
|
|
165
|
+
const meta = await createSdkClient({url: auth.resource, token, workspaceId, fetchImpl});
|
|
166
|
+
try {
|
|
167
|
+
const described = new Map();
|
|
168
|
+
for (let start = 0; start < items.length; start += 10) {
|
|
169
|
+
const names = items.slice(start, start + 10).map(item => item.name);
|
|
170
|
+
if (names.some(name => typeof name !== 'string' || !name || name.length > 64)) fail('INVALID_RESPONSE', 502);
|
|
171
|
+
const data = normalized(await meta.callTool({name: 'describe_tools',
|
|
172
|
+
arguments: {names, include: 'input+output'}}, {timeout: 30000}), token);
|
|
173
|
+
if (!Array.isArray(data.tools) || data.tools.length !== names.length || data.unknown?.length) fail('INVALID_RESPONSE', 502);
|
|
174
|
+
for (const item of data.tools) {
|
|
175
|
+
if (!names.includes(item.name) || described.has(item.name)) fail('INVALID_RESPONSE', 502);
|
|
176
|
+
described.set(item.name, item);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return {items: items.map(item => ({...item, ...described.get(item.name), annotations: item.annotations}))};
|
|
180
|
+
} finally { await meta.close().catch(() => {}); }
|
|
181
|
+
}, {safeRead: true, all: true}),
|
|
182
|
+
describeCommand: name => perform(async (client, token) => {
|
|
183
|
+
const data = normalized(await client.callTool({name: 'describe_tools', arguments: {names: [name], include: 'input+output'}}, {timeout: 30000}), token);
|
|
184
|
+
if (!Array.isArray(data?.tools) || data.tools.length !== 1) fail('INVALID_RESPONSE', 502);
|
|
185
|
+
return data.tools[0];
|
|
186
|
+
}, {safeRead: true}),
|
|
187
|
+
callCommand: (name, args = {}) => tool(name, detailed(name, args)),
|
|
188
|
+
getWorkspace: () => tool('get_workspace', {}, true, '/v1/workspace'),
|
|
189
|
+
getProject: id => tool('get_project', {projectId: id}, true, '/v1/projects/' + encodeURIComponent(id)),
|
|
190
|
+
importWatches: (id, watches) => tool('import_link_watches', {projectId: id, watches}, false, '/v1/watches/import', 'POST'),
|
|
191
|
+
updateWatch: (watchId, patch) => tool('update_link_watch', {...patch, watchId}, false, '/v1/watches/' + encodeURIComponent(watchId), 'PATCH'),
|
|
192
|
+
listWatches: query => tool('list_link_watches', scoped('list_link_watches', query), true, '/v1/watches'),
|
|
193
|
+
listEvents: query => tool('list_events', {...scoped('list_events', query), feed: 'links'}, true, '/v1/events'),
|
|
194
|
+
listTargets: query => tool('list_targets', scoped('list_targets', query), true, '/v1/targets'),
|
|
195
|
+
listTargetEvents: query => tool('list_events', {...scoped('list_events', query), feed: 'targets'}, true, '/v1/target-events'),
|
|
196
|
+
exportWatches: query => tool('export_link_watches', scoped('export_link_watches', query), true, '/v1/exports/watches'),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Only the OS advisory lock coordinates recovery. Path age is never ownership.
|
|
2
|
+
import { ConfigError } from './config.js';
|
|
3
|
+
let binding;
|
|
4
|
+
export async function nativeLock(platform = process.platform) {
|
|
5
|
+
if (!['darwin', 'linux'].includes(platform)) throw new ConfigError('Sync lock recovery is unsupported on this platform; no writer started.');
|
|
6
|
+
if (!binding) {
|
|
7
|
+
try {
|
|
8
|
+
const { default: koffi } = await import('koffi');
|
|
9
|
+
const lib = koffi.load(platform === 'darwin' ? '/usr/lib/libSystem.B.dylib' : 'libc.so.6');
|
|
10
|
+
const flock = lib.func('int flock(int fd, int operation)');
|
|
11
|
+
binding = { acquire(fd) {
|
|
12
|
+
if (flock(fd, 2 | 4) === 0) return true;
|
|
13
|
+
const code = koffi.errno();
|
|
14
|
+
if (code === koffi.os.errno.EWOULDBLOCK || code === koffi.os.errno.EAGAIN) return false;
|
|
15
|
+
throw new ConfigError('Kernel sync lock failed; no writer started.');
|
|
16
|
+
} };
|
|
17
|
+
} catch (error) {
|
|
18
|
+
throw new ConfigError('Native sync lock is unavailable; reinstall the CLI with its platform dependencies. No writer started.');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return binding;
|
|
22
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import {constants} from 'node:fs';
|
|
3
|
+
import {homedir} from 'node:os';
|
|
4
|
+
import {isAbsolute, join, parse, resolve} from 'node:path';
|
|
5
|
+
import {randomBytes} from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
const MAX_BYTES = 300000;
|
|
8
|
+
const fail = code => { throw Object.assign(new Error(code), {code}); };
|
|
9
|
+
const exact = (value, keys) => value && typeof value === 'object' && !Array.isArray(value)
|
|
10
|
+
&& Object.keys(value).sort().join(',') === [...keys].sort().join(',');
|
|
11
|
+
function bindingOf(value) {
|
|
12
|
+
if (!exact(value, ['issuer', 'resource', 'clientId'])) fail('OAUTH_CREDENTIAL_BINDING_INVALID');
|
|
13
|
+
for (const key of ['issuer', 'resource']) {
|
|
14
|
+
let url; try { url = new URL(value[key]); } catch { fail('OAUTH_CREDENTIAL_BINDING_INVALID'); }
|
|
15
|
+
if (typeof value[key] !== 'string' || value[key].length > 4096 || url.protocol !== 'https:'
|
|
16
|
+
|| url.username || url.password || url.search || url.hash
|
|
17
|
+
|| (key === 'issuer' ? url.origin !== value[key] : url.href !== value[key] || url.pathname !== '/mcp'))
|
|
18
|
+
fail('OAUTH_CREDENTIAL_BINDING_INVALID');
|
|
19
|
+
}
|
|
20
|
+
if (typeof value.clientId !== 'string' || !value.clientId || value.clientId.length > 2048
|
|
21
|
+
|| /[\s\x00-\x1f\x7f]/.test(value.clientId)) fail('OAUTH_CREDENTIAL_BINDING_INVALID');
|
|
22
|
+
return {...value};
|
|
23
|
+
}
|
|
24
|
+
function tokensOf(value) {
|
|
25
|
+
if (!exact(value, ['accessToken', 'refreshToken', 'expiresAt', 'scope'])) fail('OAUTH_CREDENTIAL_TOKENS_INVALID');
|
|
26
|
+
for (const key of ['accessToken', 'refreshToken']) if (typeof value[key] !== 'string' || !value[key]
|
|
27
|
+
|| Buffer.byteLength(value[key]) > 65536 || /[\x00-\x20\x7f]/.test(value[key])) fail('OAUTH_CREDENTIAL_TOKENS_INVALID');
|
|
28
|
+
if (!Number.isSafeInteger(value.expiresAt) || value.expiresAt <= 0 || typeof value.scope !== 'string'
|
|
29
|
+
|| !value.scope || Buffer.byteLength(value.scope) > 4096 || !/^[A-Za-z0-9:._/-]+(?: [A-Za-z0-9:._/-]+)*$/.test(value.scope))
|
|
30
|
+
fail('OAUTH_CREDENTIAL_TOKENS_INVALID');
|
|
31
|
+
return {...value};
|
|
32
|
+
}
|
|
33
|
+
const reference = ref => { if (typeof ref !== 'string' || !/^oauth_[a-f0-9]{32}$/.test(ref)) fail('OAUTH_CREDENTIAL_REF_INVALID'); return ref; };
|
|
34
|
+
const sameIdentity = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
35
|
+
const sameBinding = (a, b) => ['issuer', 'resource', 'clientId'].every(key => a[key] === b[key]);
|
|
36
|
+
const sleep = ms => new Promise(done => setTimeout(done, ms));
|
|
37
|
+
|
|
38
|
+
export function createOAuthCredentialStore({home = homedir(), platform = process.platform, now = Date.now, lockTimeoutMs = 2000} = {}) {
|
|
39
|
+
if (typeof home !== 'string' || !isAbsolute(home) || home.includes('\0')) fail('OAUTH_CREDENTIAL_HOME_INVALID');
|
|
40
|
+
if (!Number.isInteger(lockTimeoutMs) || lockTimeoutMs < 0 || lockTimeoutMs > 10000) fail('OAUTH_CREDENTIAL_LOCK_BOUND_INVALID');
|
|
41
|
+
const root = resolve(home), directory = join(root, '.agentlinkops', 'oauth');
|
|
42
|
+
const uid = process.getuid?.();
|
|
43
|
+
const privateStat = (info, mode) => info.uid === uid && (info.mode & 0o7777) === mode;
|
|
44
|
+
async function directories() {
|
|
45
|
+
// Darwin ACLs can grant access independently of these POSIX mode checks.
|
|
46
|
+
// Other platforms require separately verified native credential custody.
|
|
47
|
+
if (platform !== 'linux' || uid === undefined) fail('OAUTH_CREDENTIAL_STORAGE_UNSUPPORTED');
|
|
48
|
+
let path = parse(root).root;
|
|
49
|
+
for (const part of root.slice(path.length).split('/').filter(Boolean)) {
|
|
50
|
+
path = join(path, part);
|
|
51
|
+
const info = await fs.lstat(path);
|
|
52
|
+
if (!info.isDirectory()) fail('OAUTH_CREDENTIAL_PRIVATE_STORAGE_REQUIRED');
|
|
53
|
+
}
|
|
54
|
+
const homeInfo = await fs.lstat(root);
|
|
55
|
+
if (homeInfo.uid !== uid) fail('OAUTH_CREDENTIAL_PRIVATE_STORAGE_REQUIRED');
|
|
56
|
+
for (const path of [join(root, '.agentlinkops'), directory]) {
|
|
57
|
+
try { await fs.mkdir(path, {mode: 0o700}); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
58
|
+
const info = await fs.lstat(path);
|
|
59
|
+
if (!info.isDirectory() || !privateStat(info, 0o700)) fail('OAUTH_CREDENTIAL_PRIVATE_STORAGE_REQUIRED');
|
|
60
|
+
}
|
|
61
|
+
return fs.lstat(directory);
|
|
62
|
+
}
|
|
63
|
+
async function unchangedDirectory(original) {
|
|
64
|
+
if (!sameIdentity(original, await directories())) fail('OAUTH_CREDENTIAL_STORAGE_CHANGED');
|
|
65
|
+
}
|
|
66
|
+
async function syncDirectory() {
|
|
67
|
+
const parent = await fs.open(directory, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
68
|
+
try { await parent.sync(); } finally { await parent.close(); }
|
|
69
|
+
}
|
|
70
|
+
async function bytes(path, missing = false) {
|
|
71
|
+
let handle;
|
|
72
|
+
try {
|
|
73
|
+
const info = await fs.lstat(path);
|
|
74
|
+
if (!info.isFile() || info.nlink !== 1 || !privateStat(info, 0o600)) fail('OAUTH_CREDENTIAL_PRIVATE_STORAGE_REQUIRED');
|
|
75
|
+
handle = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
76
|
+
const actual = await handle.stat();
|
|
77
|
+
if (!sameIdentity(info, actual) || !actual.isFile() || actual.nlink !== 1 || !privateStat(actual, 0o600)) fail('OAUTH_CREDENTIAL_STORAGE_CHANGED');
|
|
78
|
+
if (actual.size > MAX_BYTES) fail('OAUTH_CREDENTIAL_RECORD_INVALID');
|
|
79
|
+
const buffer = Buffer.alloc(MAX_BYTES + 1);
|
|
80
|
+
let offset = 0;
|
|
81
|
+
while (offset < buffer.length) {
|
|
82
|
+
const read = await handle.read(buffer, offset, buffer.length - offset, offset);
|
|
83
|
+
if (!read.bytesRead) break;
|
|
84
|
+
offset += read.bytesRead;
|
|
85
|
+
}
|
|
86
|
+
if (offset > MAX_BYTES) fail('OAUTH_CREDENTIAL_RECORD_INVALID');
|
|
87
|
+
return buffer.subarray(0, offset).toString('utf8');
|
|
88
|
+
} catch (error) { if (missing && error.code === 'ENOENT') return null; throw error; }
|
|
89
|
+
finally { await handle?.close(); }
|
|
90
|
+
}
|
|
91
|
+
function recordOf(text, binding) {
|
|
92
|
+
let record; try { record = JSON.parse(text); } catch { fail('OAUTH_CREDENTIAL_RECORD_INVALID'); }
|
|
93
|
+
if (!exact(record, ['version', 'revision', 'binding', 'tokens']) || record.version !== 1
|
|
94
|
+
|| !Number.isSafeInteger(record.revision) || record.revision < 1) fail('OAUTH_CREDENTIAL_RECORD_INVALID');
|
|
95
|
+
const stored = bindingOf(record.binding), tokens = tokensOf(record.tokens);
|
|
96
|
+
if (!sameBinding(stored, binding)) fail('OAUTH_CREDENTIAL_BINDING_MISMATCH');
|
|
97
|
+
return {version: 1, revision: record.revision, binding: stored, tokens};
|
|
98
|
+
}
|
|
99
|
+
async function atomic(ref, record, before, original) {
|
|
100
|
+
const path = join(directory, ref + '.json'), temporary = join(directory, '.' + ref + '.' + randomBytes(16).toString('hex') + '.tmp');
|
|
101
|
+
let handle, owned = false;
|
|
102
|
+
try {
|
|
103
|
+
const text = JSON.stringify(record);
|
|
104
|
+
if (Buffer.byteLength(text) > MAX_BYTES) fail('OAUTH_CREDENTIAL_RECORD_INVALID');
|
|
105
|
+
handle = await fs.open(temporary, 'wx', 0o600); owned = true;
|
|
106
|
+
if (!privateStat(await handle.stat(), 0o600)) fail('OAUTH_CREDENTIAL_PRIVATE_STORAGE_REQUIRED');
|
|
107
|
+
await handle.writeFile(text); await handle.sync(); await handle.close(); handle = undefined;
|
|
108
|
+
await unchangedDirectory(original);
|
|
109
|
+
if (await bytes(path, true) !== before) fail('OAUTH_CREDENTIAL_STORAGE_CHANGED');
|
|
110
|
+
if (before === null) { await fs.link(temporary, path); await fs.unlink(temporary); }
|
|
111
|
+
else await fs.rename(temporary, path);
|
|
112
|
+
owned = false;
|
|
113
|
+
await syncDirectory();
|
|
114
|
+
} finally { await handle?.close(); if (owned) await fs.unlink(temporary).catch(() => {}); }
|
|
115
|
+
}
|
|
116
|
+
async function locked(ref, work) {
|
|
117
|
+
const original = await directories(), path = join(directory, ref + '.lock'), nonce = randomBytes(16).toString('hex');
|
|
118
|
+
const deadline = Date.now() + lockTimeoutMs;
|
|
119
|
+
let lock;
|
|
120
|
+
while (!lock) {
|
|
121
|
+
try { lock = await fs.open(path, 'wx', 0o600); }
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error.code !== 'EEXIST') throw error;
|
|
124
|
+
if (Date.now() >= deadline) fail('OAUTH_CREDENTIAL_LOCKED');
|
|
125
|
+
await sleep(20);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
let identity, initialized = false;
|
|
129
|
+
try {
|
|
130
|
+
identity = await lock.stat();
|
|
131
|
+
if (!privateStat(identity, 0o600)) fail('OAUTH_CREDENTIAL_PRIVATE_STORAGE_REQUIRED');
|
|
132
|
+
await lock.writeFile(nonce); await lock.sync();
|
|
133
|
+
initialized = true;
|
|
134
|
+
return await work(original);
|
|
135
|
+
} finally {
|
|
136
|
+
try {
|
|
137
|
+
// Before initialization, the open inode proves ownership even if the nonce write failed.
|
|
138
|
+
identity ??= await lock.stat().catch(() => null);
|
|
139
|
+
const current = await fs.lstat(path).catch(() => null);
|
|
140
|
+
if (identity && current && sameIdentity(identity, current)
|
|
141
|
+
&& (!initialized || await bytes(path) === nonce)) await fs.unlink(path);
|
|
142
|
+
} finally { await lock.close(); }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function safe(work) {
|
|
146
|
+
try { return await work(); }
|
|
147
|
+
catch (error) { if (typeof error.code === 'string' && error.code.startsWith('OAUTH_CREDENTIAL_')) throw error; fail('OAUTH_CREDENTIAL_STORAGE_FAILED'); }
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
async checkAvailability() { return safe(async () => { await directories(); }); },
|
|
151
|
+
async create(binding, tokens) {
|
|
152
|
+
binding = bindingOf(binding); tokens = tokensOf(tokens);
|
|
153
|
+
return safe(async () => {
|
|
154
|
+
const ref = 'oauth_' + randomBytes(16).toString('hex');
|
|
155
|
+
await locked(ref, original => atomic(ref, {version: 1, revision: 1, binding, tokens}, null, original));
|
|
156
|
+
return ref;
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
async read(ref, binding) {
|
|
160
|
+
reference(ref); binding = bindingOf(binding);
|
|
161
|
+
return safe(async () => { const original = await directories(); const text = await bytes(join(directory, ref + '.json'), true);
|
|
162
|
+
if (text === null) fail('OAUTH_CREDENTIAL_NOT_FOUND');
|
|
163
|
+
const record = recordOf(text, binding); await unchangedDirectory(original); return record; });
|
|
164
|
+
},
|
|
165
|
+
async withCredential(ref, binding, callback) {
|
|
166
|
+
reference(ref); binding = bindingOf(binding);
|
|
167
|
+
if (typeof callback !== 'function') fail('OAUTH_CREDENTIAL_CALLBACK_INVALID');
|
|
168
|
+
let callbackError, callbackFailed = false;
|
|
169
|
+
try { return await safe(() => locked(ref, async original => {
|
|
170
|
+
const path = join(directory, ref + '.json'), before = await bytes(path, true);
|
|
171
|
+
if (before === null) fail('OAUTH_CREDENTIAL_NOT_FOUND');
|
|
172
|
+
const record = recordOf(before, binding);
|
|
173
|
+
let result; try { result = await callback(structuredClone(record)); } catch (error) { callbackFailed = true; callbackError = error; throw error; }
|
|
174
|
+
if (!result || typeof result !== 'object' || !Object.hasOwn(result, 'value')
|
|
175
|
+
|| Object.keys(result).some(key => !['tokens', 'value'].includes(key))) fail('OAUTH_CREDENTIAL_CALLBACK_INVALID');
|
|
176
|
+
await unchangedDirectory(original);
|
|
177
|
+
if (await bytes(path) !== before) fail('OAUTH_CREDENTIAL_STORAGE_CHANGED');
|
|
178
|
+
if (result.tokens !== undefined) {
|
|
179
|
+
if (record.revision === Number.MAX_SAFE_INTEGER) fail('OAUTH_CREDENTIAL_RECORD_INVALID');
|
|
180
|
+
await atomic(ref, {...record, revision: record.revision + 1, tokens: tokensOf(result.tokens)}, before, original);
|
|
181
|
+
}
|
|
182
|
+
return result.value;
|
|
183
|
+
})); } catch (error) { if (callbackFailed) throw callbackError; throw error; }
|
|
184
|
+
},
|
|
185
|
+
async remove(ref, binding) {
|
|
186
|
+
reference(ref); binding = bindingOf(binding);
|
|
187
|
+
return safe(() => locked(ref, async original => {
|
|
188
|
+
const path = join(directory, ref + '.json'), before = await bytes(path, true);
|
|
189
|
+
if (before === null) return false;
|
|
190
|
+
recordOf(before, binding); await unchangedDirectory(original);
|
|
191
|
+
if (await bytes(path) !== before) fail('OAUTH_CREDENTIAL_STORAGE_CHANGED');
|
|
192
|
+
await fs.unlink(path); await syncDirectory(); return true;
|
|
193
|
+
}));
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|