agentlinkops 0.6.9 → 0.6.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +18 -0
- package/README.md +44 -12
- package/cli/admission.js +9 -1
- package/cli/agent-setup.js +189 -20
- package/cli/catalog.js +7938 -1357
- package/cli/client.js +89 -23
- package/cli/cloud-error.js +21 -0
- package/cli/commands.js +15 -3
- package/cli/connection.js +74 -12
- package/cli/doctor.js +24 -9
- package/cli/gsc-browser.js +19 -6
- package/cli/import.js +4 -1
- package/cli/ledger.js +4 -7
- package/cli/lifecycle.js +5 -2
- package/cli/local-result.js +4 -4
- package/cli/main.js +50 -34
- package/cli/mcp-client.js +198 -0
- package/cli/native-lock.js +22 -0
- package/cli/oauth-credentials.js +196 -0
- package/cli/oauth-flow.js +259 -0
- package/cli/setup-plan.js +5 -5
- package/cli/status.js +14 -2
- package/cli/sync-lock.js +105 -0
- package/cli/sync.js +14 -6
- package/package.json +6 -3
- package/references/cli.md +22 -0
- package/references/local-crm-draft-handoff.md +76 -0
- package/references/portable-ledger-handoff.md +50 -0
- package/references/recipes/catalog.json +9 -9
- package/references/recipes/command-notes.md +17 -1
- package/references/recipes/placement-reconciliation.md +19 -5
- package/references/recipes/qualified-campaign-handoff.md +16 -4
- package/references/recipes/site-context-brief.md +2 -2
- package/references/recipes/sourced-linkable-asset.md +8 -8
- package/scripts/agentlinkops.py +30 -5
- package/scripts/outreach-crm-draft.py +121 -0
- package/scripts/portable-handoff.py +184 -0
- package/shared/lifecycle-contract.js +2 -2
- package/shared/response-contract.js +7 -1
- package/skills/agentlinkops-assets/SKILL.md +2 -2
- package/skills/agentlinkops-campaigns/SKILL.md +3 -3
- package/skills/agentlinkops-connect/SKILL.md +29 -6
- package/skills/agentlinkops-connect/references/agentlinkops.md +34 -13
- package/skills/agentlinkops-crm/SKILL.md +18 -4
- package/src/citations/adapter.js +3 -2
- package/src/citations/browser/engine.js +23 -7
- package/src/citations/login.js +42 -5
- package/src/discovery/contract.js +1 -1
- package/src/response-shaping.js +58 -0
- package/src/tool-views.js +3 -1
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
|
|
@@ -114,8 +115,9 @@ const USAGE = `agentlinkops — a backlink ledger that lives in your repository
|
|
|
114
115
|
Suppliers: ${SUPPLIER_NAMES.join(', ')}
|
|
115
116
|
A preset is a convenience over --map, never a requirement.
|
|
116
117
|
|
|
117
|
-
Exit codes: 0
|
|
118
|
-
evidence · 2 usage, configuration or ledger error.
|
|
118
|
+
Exit codes: 0 no expected link conclusively absent · 1 an expected link observed absent with complete
|
|
119
|
+
evidence · 2 usage, configuration or ledger error. Unknown is nonfailing by default;
|
|
120
|
+
--fail-on-unknown returns 1 when any observation is unknown.
|
|
119
121
|
|
|
120
122
|
Names: \`linktrail\` still runs this CLI, LINKTRAIL_* variables are still read, and an existing
|
|
121
123
|
.linktrail/ directory is still used, each with a one-line notice, until the DP-0029 cutover.`;
|
|
@@ -133,7 +135,7 @@ function reportProblems(problems, what, out) {
|
|
|
133
135
|
if (problems.length > 20) out(` … and ${problems.length - 20} more`);
|
|
134
136
|
}
|
|
135
137
|
|
|
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 } = {}) {
|
|
138
|
+
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
139
|
if (argv[0] === 'gsc-links') return await gscLinksMain(argv, { cwd, out });
|
|
138
140
|
// Citation watches keep their own files under .agentlinkops/citations/ and never
|
|
139
141
|
// touch the link ledger, so they dispatch before its requirement like context does.
|
|
@@ -147,7 +149,9 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
|
|
|
147
149
|
|
|
148
150
|
let syncLock = null;
|
|
149
151
|
try {
|
|
150
|
-
if (command
|
|
152
|
+
if (args['recover-cursors'] !== undefined && (command !== 'sync' || args['recover-cursors'] !== true || args['pull-only'] !== true || args['push-only']))
|
|
153
|
+
throw new ConfigError('Cursor recovery requires agentlinkops sync --pull-only --recover-cursors; it performs no remote writes.');
|
|
154
|
+
if (command === 'lifecycle') return await lifecycleMain(args,{cwd,env,fetchImpl,out,err});
|
|
151
155
|
if (command === 'admission') { const config=await loadConfig({cwd}); return await admissionMain(argv.slice(1),{client:createClient({...cloudConnection(config,env),fetchImpl}),out,err}); }
|
|
152
156
|
if (command === 'disavow') return await disavowMain(args,{cwd,env,fetchImpl,out});
|
|
153
157
|
if (command === 'index') return await indexMain(args,{cwd,env,fetchImpl,out});
|
|
@@ -156,8 +160,8 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
|
|
|
156
160
|
if (command === 'skill') return await skillMain(argv, { out });
|
|
157
161
|
if (command === 'agent') return await agentMain(argv, { cwd, out, err });
|
|
158
162
|
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 });
|
|
163
|
+
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]]');
|
|
164
|
+
return await connectMain(args, { cwd, out, env, fetchImpl });
|
|
161
165
|
}
|
|
162
166
|
if (command === 'check' && (args.source !== undefined || args.target !== undefined)) {
|
|
163
167
|
if (typeof args.source !== 'string' || typeof args.target !== 'string') throw new ConfigError('check requires both --source URL and --target URL');
|
|
@@ -329,9 +333,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
|
|
|
329
333
|
|
|
330
334
|
const config = await loadConfig({ cwd });
|
|
331
335
|
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; }
|
|
336
|
+
syncLock = await acquireSyncLock(config.dir, command);
|
|
335
337
|
}
|
|
336
338
|
if (command === 'adopt-result') {
|
|
337
339
|
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 +508,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
|
|
|
506
508
|
for (const change of found.slice(0, 100)) out(` ${change.at.slice(0, 10)} ${change.from} -> ${change.to} ${change.id}`);
|
|
507
509
|
return 0;
|
|
508
510
|
}
|
|
509
|
-
const groups = { appeared: 'appeared', lost: 'LOST', cannot_say: 'cannot say', never_checked: 'never checked' };
|
|
511
|
+
const groups = { appeared: 'appeared', lost: 'LOST', suspected_missing: 'suspected missing', cannot_say: 'cannot say', never_checked: 'never checked' };
|
|
510
512
|
for (const [kind, label] of Object.entries(groups)) {
|
|
511
513
|
const rows = found.filter(item => item.kind === kind);
|
|
512
514
|
if (!rows.length) continue;
|
|
@@ -524,7 +526,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
|
|
|
524
526
|
if (command === 'receive') {
|
|
525
527
|
if (!args.body || !args.headers) throw new ConfigError('receive --body RAW_BODY_FILE --headers HEADERS_JSON_FILE');
|
|
526
528
|
verifyDelivery({ body: await readFile(String(args.body), 'utf8'), headers: JSON.parse(await readFile(String(args.headers), 'utf8')),
|
|
527
|
-
secret: readEnv(
|
|
529
|
+
secret: readEnv(env, 'WEBHOOK_SECRET').value, workspaceId: config.cloud?.workspaceId });
|
|
528
530
|
args['pull-only'] = true;
|
|
529
531
|
args['push-only'] = false;
|
|
530
532
|
}
|
|
@@ -533,11 +535,11 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
|
|
|
533
535
|
if(args.lifecycle&&args['push-only'])throw new ConfigError('--lifecycle requires a pull; omit --push-only.');
|
|
534
536
|
const selection = { includeWanted: args['include-wanted'] === true, ledgerIds: config.cloud?.ledgerIds ?? null };
|
|
535
537
|
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);
|
|
538
|
+
const connection = cloudConnection(config, env);
|
|
537
539
|
if (!connection.origin) { err('no cloud origin — run agentlinkops connect'); return 2; }
|
|
538
|
-
if (!connection.token) { err(keySetup(connection.origin)); return 2; }
|
|
540
|
+
if (!connection.token && !connection.auth) { err(keySetup(connection.origin)); return 2; }
|
|
539
541
|
if (!connection.projectId) { err('no project id — run agentlinkops connect'); return 2; }
|
|
540
|
-
const client = createClient(connection);
|
|
542
|
+
const client = createClient({ ...connection, fetchImpl });
|
|
541
543
|
let next = { ...state };
|
|
542
544
|
let pushFailed = false;
|
|
543
545
|
if (!args['pull-only']) {
|
|
@@ -549,36 +551,47 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
|
|
|
549
551
|
pushFailed = pushed.failed.length > 0;
|
|
550
552
|
}
|
|
551
553
|
if (!args['push-only']) {
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
554
|
+
const snapshots = [];
|
|
555
|
+
const recovery = {
|
|
556
|
+
recoverInvalidCursor: args['recover-cursors'] === true,
|
|
557
|
+
onSnapshot: async (snapshot, feed) => {
|
|
558
|
+
const path = join(config.dir, `snapshot-${feed}-${Date.now()}-${crypto.randomUUID()}.json`);
|
|
559
|
+
await writeFile(path, `${JSON.stringify(snapshot, null, 1)}\n`, {encoding: 'utf8', flag: 'wx', mode: 0o600});
|
|
560
|
+
snapshots.push({feed, path});
|
|
557
561
|
},
|
|
562
|
+
};
|
|
563
|
+
const pulled = await pullEvents(client, next, {
|
|
564
|
+
...recovery,
|
|
558
565
|
});
|
|
559
566
|
// Two feeds, two cursors. They are pulled separately and written separately, so an
|
|
560
567
|
// expiry on one cannot move the other.
|
|
561
|
-
const targets = await pullTargetEvents(client, next);
|
|
568
|
+
const targets = await pullTargetEvents(client, next, recovery);
|
|
562
569
|
const index = await watchIndex(client, next);
|
|
563
570
|
if(args.lifecycle)next.lifecycle=await pullLifecycleMirrors(client,next,{projectId:connection.projectId,index,ledgerIds:selection.ledgerIds??ledger.entries.map(entry=>entry.id)});
|
|
564
571
|
const { fresh, duplicates } = await persistPulledHistory(config, pulled.events, targets.events, index);
|
|
572
|
+
await afterHistoryPersisted?.();
|
|
565
573
|
next.cursors = { ...(next.cursors ?? {}), events: pulled.cursor, target_events: targets.cursor };
|
|
566
574
|
next.gaps = [...(next.gaps ?? []), ...pulled.gaps, ...targets.gaps];
|
|
567
575
|
out(`pulled ${pulled.events.length} event(s) over ${pulled.pages} page(s), ${fresh.length} observation(s)${duplicates.length ? `, ${duplicates.length} already held` : ''}`);
|
|
568
576
|
if (targets.events.length) out(`pulled ${targets.events.length} target event(s) over ${targets.pages} page(s)`);
|
|
569
577
|
// A gap is stated, never inferred. An empty feed means nothing happened; an expired
|
|
570
578
|
// cursor means something happened and is gone.
|
|
571
|
-
for (const gap of [...pulled.gaps, ...targets.gaps])
|
|
579
|
+
for (const gap of [...pulled.gaps, ...targets.gaps]) {
|
|
580
|
+
const snapshot = snapshots.find(item => item.feed === gap.feed);
|
|
581
|
+
out(` RESYNC ${gap.at}: ${gap.reason}; ${snapshot ? `snapshot saved to ${snapshot.path}` : 'current-state snapshot applied'}`);
|
|
582
|
+
}
|
|
572
583
|
}
|
|
573
584
|
await writeState(config.paths.state, next);
|
|
585
|
+
await afterStatePersisted?.();
|
|
574
586
|
return pushFailed ? 2 : 0;
|
|
575
587
|
}
|
|
576
588
|
|
|
577
589
|
if (command === 'check') {
|
|
590
|
+
if (ledger.problems.length) { reportProblems(ledger.problems, 'ledger line(s)', err); return 2; }
|
|
578
591
|
const state = await readState(config.paths.state);
|
|
579
592
|
const selected = selectEntries(ledger.entries, { filter: args.filter === true ? null : args.filter });
|
|
580
593
|
const entries = args.all ? selected : dueEntries(selected, lastCheckedMap(state));
|
|
581
|
-
if (!entries.length) { out(`nothing due (${plural(selected.length, 'entry', 'entries')} in scope; pass --all to recheck)`); return 0; }
|
|
594
|
+
if (!entries.length) { (args.json ? err : out)(`nothing due (${plural(selected.length, 'entry', 'entries')} in scope; pass --all to recheck)`); return 0; }
|
|
582
595
|
if (!args.json) err(`checking ${plural(entries.length, 'entry', 'entries')}…`);
|
|
583
596
|
const rows = await runCheck(entries, {
|
|
584
597
|
concurrency: Number(args.concurrency ?? config.defaults.concurrency ?? DEFAULTS.concurrency),
|
|
@@ -592,26 +605,29 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
|
|
|
592
605
|
await appendObservations(config.paths.observations, changed);
|
|
593
606
|
await writeState(config.paths.state, applyRun(state, rows));
|
|
594
607
|
const summary = summarize(entries, rows);
|
|
595
|
-
|
|
596
|
-
|
|
608
|
+
const exitCode = args['fail-on-unknown'] && summary.counts.unknown ? 1 : summary.exitCode;
|
|
609
|
+
if (args.json) { out(rows.map(row => JSON.stringify(row)).join('\n')); return exitCode; }
|
|
597
610
|
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(`
|
|
611
|
+
for (const { entry, row } of summary.failures) out(`ABSENT ${entry.id} ${entry.source} [${row.reason}]`);
|
|
599
612
|
out(`\n${JSON.stringify(summary.counts)}`);
|
|
600
613
|
out(`${plural(changed.length, 'observation')} recorded, ${repeated.length} repeated an answer already on file`);
|
|
601
614
|
if (summary.counts.unknown) {
|
|
602
615
|
out(`${plural(summary.counts.unknown, 'page')} could not be concluded. An unknown is not a lost link; run \`agentlinkops status\` for the reasons.`);
|
|
603
616
|
}
|
|
604
|
-
|
|
605
|
-
return summary.exitCode;
|
|
617
|
+
return exitCode;
|
|
606
618
|
}
|
|
607
619
|
|
|
608
620
|
err(`unknown command: ${command}\n\n${USAGE}`);
|
|
609
621
|
return 2;
|
|
610
622
|
} catch (error) {
|
|
611
623
|
if (error instanceof ConfigError) { err(error.message); return 2; }
|
|
624
|
+
if (command === 'sync' && args['recover-cursors'] === true && error instanceof CloudError) {
|
|
625
|
+
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.'}}));
|
|
626
|
+
return 2;
|
|
627
|
+
}
|
|
612
628
|
err(`agentlinkops: ${error?.message ?? error}`);
|
|
613
629
|
return 2;
|
|
614
630
|
} finally {
|
|
615
|
-
if (syncLock)
|
|
631
|
+
if (syncLock) await syncLock.release();
|
|
616
632
|
}
|
|
617
633
|
}
|
|
@@ -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.11'});
|
|
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.11');
|
|
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
|
+
}
|