@adhdev/daemon-core 0.9.82-rc.165 → 0.9.82-rc.166
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/dist/commands/handler.d.ts +61 -17
- package/dist/index.js +1298 -752
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1296 -750
- package/dist/index.mjs.map +1 -1
- package/dist/providers/external-sources.d.ts +71 -0
- package/dist/providers/provider-loader.d.ts +7 -3
- package/dist/providers/provider-trust.d.ts +31 -0
- package/dist/providers/sdk/v1/index.d.ts +1 -1
- package/dist/providers/sdk/v1/validators/manifest.d.ts +1 -1
- package/dist/providers/sdk/v1/validators/taint.d.ts +1 -1
- package/dist/shared-types.d.ts +27 -1
- package/package.json +1 -1
- package/src/commands/cli-manager.ts +19 -0
- package/src/commands/handler.ts +286 -24
- package/src/providers/external-sources.ts +218 -0
- package/src/providers/provider-loader.ts +159 -23
- package/src/providers/provider-trust.ts +114 -0
- package/src/providers/sdk/v1/index.ts +1 -1
- package/src/providers/sdk/v1/sandbox/require-whitelist.ts +1 -1
- package/src/providers/sdk/v1/validators/manifest.ts +1 -1
- package/src/providers/sdk/v1/validators/taint.ts +1 -1
- package/src/shared-types.ts +33 -1
- package/src/status/snapshot.ts +49 -14
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External provider sources — registry + active-selection state.
|
|
3
|
+
*
|
|
4
|
+
* A user can register one or more 3rd-party git URLs as provider sources.
|
|
5
|
+
* Each source clones into ~/.adhdev/external/<source-name>/, namespaced so
|
|
6
|
+
* that two sources can disk-coexist providing the same provider type.
|
|
7
|
+
*
|
|
8
|
+
* When two sources expose the same type, only one is "active" at a time.
|
|
9
|
+
* The active selection is persisted so a user's choice survives daemon
|
|
10
|
+
* restarts. When only one source provides a type, no explicit selection
|
|
11
|
+
* is needed and the loader falls through to that source.
|
|
12
|
+
*/
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as os from 'node:os';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
|
|
19
|
+
export interface ExternalSource {
|
|
20
|
+
/** Unique short identifier, e.g. "@vendor-x". User-supplied or derived from the URL. */
|
|
21
|
+
name: string;
|
|
22
|
+
/** Full git URL (https://, git@, …). */
|
|
23
|
+
url: string;
|
|
24
|
+
/** Branch / tag / commit-ish to track. Defaults to `main`. */
|
|
25
|
+
ref: string;
|
|
26
|
+
/** ISO timestamp when first registered. */
|
|
27
|
+
addedAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ExternalSourcesFile {
|
|
31
|
+
/** Schema version — bump if shape changes. */
|
|
32
|
+
schema: 1;
|
|
33
|
+
sources: ExternalSource[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Per-type active source selection. Only types reachable from more than
|
|
38
|
+
* one source need an entry — single-source types have no ambiguity.
|
|
39
|
+
*
|
|
40
|
+
* Shape: { active: { "<type>": "<source-name>" } }
|
|
41
|
+
*/
|
|
42
|
+
export interface ProvidersActiveFile {
|
|
43
|
+
schema: 1;
|
|
44
|
+
active: Record<string, string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SOURCES_FILENAME = 'providers-sources.json';
|
|
48
|
+
const ACTIVE_FILENAME = 'providers-active.json';
|
|
49
|
+
|
|
50
|
+
function adhdevDir(): string {
|
|
51
|
+
return path.join(os.homedir(), '.adhdev');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function externalRoot(): string {
|
|
55
|
+
return path.join(adhdevDir(), 'external');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function sourcesFilePath(): string {
|
|
59
|
+
return path.join(adhdevDir(), SOURCES_FILENAME);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function activeFilePath(): string {
|
|
63
|
+
return path.join(adhdevDir(), ACTIVE_FILENAME);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function ensureAdhdevDir(): void {
|
|
67
|
+
const d = adhdevDir();
|
|
68
|
+
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function loadExternalSources(): ExternalSourcesFile {
|
|
72
|
+
const p = sourcesFilePath();
|
|
73
|
+
if (!fs.existsSync(p)) return { schema: 1, sources: [] };
|
|
74
|
+
try {
|
|
75
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
76
|
+
if (!raw || typeof raw !== 'object') return { schema: 1, sources: [] };
|
|
77
|
+
const sources = Array.isArray(raw.sources) ? raw.sources.filter(isValidSource) : [];
|
|
78
|
+
return { schema: 1, sources };
|
|
79
|
+
} catch {
|
|
80
|
+
return { schema: 1, sources: [] };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function saveExternalSources(file: ExternalSourcesFile): void {
|
|
85
|
+
ensureAdhdevDir();
|
|
86
|
+
const tmp = sourcesFilePath() + '.tmp';
|
|
87
|
+
fs.writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', 'utf-8');
|
|
88
|
+
fs.renameSync(tmp, sourcesFilePath());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function loadProvidersActive(): ProvidersActiveFile {
|
|
92
|
+
const p = activeFilePath();
|
|
93
|
+
if (!fs.existsSync(p)) return { schema: 1, active: {} };
|
|
94
|
+
try {
|
|
95
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
96
|
+
if (!raw || typeof raw !== 'object') return { schema: 1, active: {} };
|
|
97
|
+
const active = raw.active && typeof raw.active === 'object' ? raw.active : {};
|
|
98
|
+
return { schema: 1, active };
|
|
99
|
+
} catch {
|
|
100
|
+
return { schema: 1, active: {} };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function saveProvidersActive(file: ProvidersActiveFile): void {
|
|
105
|
+
ensureAdhdevDir();
|
|
106
|
+
const tmp = activeFilePath() + '.tmp';
|
|
107
|
+
fs.writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', 'utf-8');
|
|
108
|
+
fs.renameSync(tmp, activeFilePath());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isValidSource(x: unknown): x is ExternalSource {
|
|
112
|
+
if (!x || typeof x !== 'object') return false;
|
|
113
|
+
const s = x as Record<string, unknown>;
|
|
114
|
+
return typeof s.name === 'string' && s.name.length > 0
|
|
115
|
+
&& typeof s.url === 'string' && s.url.length > 0
|
|
116
|
+
&& typeof s.ref === 'string' && s.ref.length > 0
|
|
117
|
+
&& typeof s.addedAt === 'string';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Derive a short identifier from a git URL when the user didn't supply one.
|
|
122
|
+
* https://github.com/vendor/extra-providers.git → "@vendor-extra-providers"
|
|
123
|
+
* git@github.com:vendor/extra-providers → "@vendor-extra-providers"
|
|
124
|
+
*
|
|
125
|
+
* Idempotent; safe to call before validation since it produces a string
|
|
126
|
+
* regardless of input shape.
|
|
127
|
+
*/
|
|
128
|
+
export function deriveSourceName(url: string): string {
|
|
129
|
+
const m = url.match(/[/:]([^/:]+)\/([^/]+?)(?:\.git)?$/);
|
|
130
|
+
if (!m) return '@source';
|
|
131
|
+
const owner = m[1].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
|
|
132
|
+
const repo = m[2].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
|
|
133
|
+
return `@${owner}-${repo}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Return the per-source category/type tree currently on disk under
|
|
138
|
+
* ~/.adhdev/external/<source>/. Used by the conflict detector and by
|
|
139
|
+
* list_provider_sources.
|
|
140
|
+
*/
|
|
141
|
+
export interface SourceInventoryEntry {
|
|
142
|
+
sourceName: string;
|
|
143
|
+
/** Map: category → list of provider types. */
|
|
144
|
+
providers: Record<string, string[]>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function inventoryExternalSources(): SourceInventoryEntry[] {
|
|
148
|
+
const root = externalRoot();
|
|
149
|
+
if (!fs.existsSync(root)) return [];
|
|
150
|
+
const out: SourceInventoryEntry[] = [];
|
|
151
|
+
let entries: fs.Dirent[];
|
|
152
|
+
try { entries = fs.readdirSync(root, { withFileTypes: true }); }
|
|
153
|
+
catch { return []; }
|
|
154
|
+
for (const sourceEntry of entries) {
|
|
155
|
+
if (!sourceEntry.isDirectory()) continue;
|
|
156
|
+
const sourceName = sourceEntry.name;
|
|
157
|
+
const sourceDir = path.join(root, sourceName);
|
|
158
|
+
const providers: Record<string, string[]> = {};
|
|
159
|
+
let categoryEntries: fs.Dirent[];
|
|
160
|
+
try { categoryEntries = fs.readdirSync(sourceDir, { withFileTypes: true }); }
|
|
161
|
+
catch { continue; }
|
|
162
|
+
for (const categoryEntry of categoryEntries) {
|
|
163
|
+
if (!categoryEntry.isDirectory()) continue;
|
|
164
|
+
const category = categoryEntry.name;
|
|
165
|
+
const categoryDir = path.join(sourceDir, category);
|
|
166
|
+
let typeEntries: fs.Dirent[];
|
|
167
|
+
try { typeEntries = fs.readdirSync(categoryDir, { withFileTypes: true }); }
|
|
168
|
+
catch { continue; }
|
|
169
|
+
const types: string[] = [];
|
|
170
|
+
for (const typeEntry of typeEntries) {
|
|
171
|
+
if (!typeEntry.isDirectory()) continue;
|
|
172
|
+
// Only count it as a provider if a manifest file exists
|
|
173
|
+
const typeDir = path.join(categoryDir, typeEntry.name);
|
|
174
|
+
const hasV1 = fs.existsSync(path.join(typeDir, 'provider.v1.json'));
|
|
175
|
+
const hasV0 = fs.existsSync(path.join(typeDir, 'provider.json'));
|
|
176
|
+
if (hasV1 || hasV0) types.push(typeEntry.name);
|
|
177
|
+
}
|
|
178
|
+
if (types.length > 0) providers[category] = types;
|
|
179
|
+
}
|
|
180
|
+
out.push({ sourceName, providers });
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* For a given category+type, list every source that currently exposes it.
|
|
187
|
+
* Returns source names in disk-walk order; callers can use the first one
|
|
188
|
+
* when no explicit active selection exists.
|
|
189
|
+
*/
|
|
190
|
+
export function sourcesProviding(category: string, type: string): string[] {
|
|
191
|
+
const inventory = inventoryExternalSources();
|
|
192
|
+
return inventory
|
|
193
|
+
.filter(s => (s.providers[category] || []).includes(type))
|
|
194
|
+
.map(s => s.sourceName);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Resolve which source should be active for a given category+type.
|
|
199
|
+
* - If exactly one source provides it → that source.
|
|
200
|
+
* - If multiple sources provide it → the one named in providers-active.json
|
|
201
|
+
* (when present) or null (ambiguous; loader should warn and pick
|
|
202
|
+
* the first deterministically so daemon doesn't refuse to boot).
|
|
203
|
+
* - If none → null.
|
|
204
|
+
*/
|
|
205
|
+
export function resolveActiveSource(
|
|
206
|
+
category: string,
|
|
207
|
+
type: string,
|
|
208
|
+
activeFile?: ProvidersActiveFile,
|
|
209
|
+
): { source: string | null; ambiguous: boolean; candidates: string[] } {
|
|
210
|
+
const candidates = sourcesProviding(category, type);
|
|
211
|
+
if (candidates.length === 0) return { source: null, ambiguous: false, candidates };
|
|
212
|
+
if (candidates.length === 1) return { source: candidates[0], ambiguous: false, candidates };
|
|
213
|
+
const explicit = (activeFile ?? loadProvidersActive()).active[type];
|
|
214
|
+
if (explicit && candidates.includes(explicit)) {
|
|
215
|
+
return { source: explicit, ambiguous: false, candidates };
|
|
216
|
+
}
|
|
217
|
+
return { source: candidates[0], ambiguous: true, candidates };
|
|
218
|
+
}
|
|
@@ -215,6 +215,33 @@ export class ProviderLoader {
|
|
|
215
215
|
sourceMode: options?.sourceMode,
|
|
216
216
|
disableUpstream: options?.disableUpstream,
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
// One-time migration: ~/.adhdev/marketplace → ~/.adhdev/external.
|
|
220
|
+
// The directory was renamed when the "marketplace" install model was
|
|
221
|
+
// dropped in favour of explicit external git sources. Best-effort:
|
|
222
|
+
// if the rename fails we leave both dirs in place and log so the user
|
|
223
|
+
// can investigate.
|
|
224
|
+
this.migrateMarketplaceDirToExternal();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private migrateMarketplaceDirToExternal(): void {
|
|
228
|
+
try {
|
|
229
|
+
const home = os.homedir();
|
|
230
|
+
const oldDir = path.join(home, '.adhdev', 'marketplace');
|
|
231
|
+
const newDir = path.join(home, '.adhdev', 'external');
|
|
232
|
+
if (!fs.existsSync(oldDir)) return;
|
|
233
|
+
if (fs.existsSync(newDir)) {
|
|
234
|
+
// Both exist — don't merge. Leave old in place; surface in logs so
|
|
235
|
+
// the user can decide what to keep. Loader still loads from
|
|
236
|
+
// external/ only, so old marketplace/ becomes inert.
|
|
237
|
+
this.log(`Migration skipped: both ~/.adhdev/marketplace and ~/.adhdev/external exist (marketplace dir is now inert and can be removed manually).`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
fs.renameSync(oldDir, newDir);
|
|
241
|
+
this.log(`Migrated ~/.adhdev/marketplace → ~/.adhdev/external (one-time rename after provider source-layer cleanup).`);
|
|
242
|
+
} catch (e: any) {
|
|
243
|
+
this.log(`Marketplace→external migration failed: ${e?.message || e}`);
|
|
244
|
+
}
|
|
218
245
|
}
|
|
219
246
|
|
|
220
247
|
private log(msg: string): void {
|
|
@@ -246,12 +273,12 @@ export class ProviderLoader {
|
|
|
246
273
|
* Highest-priority editable overrides come first.
|
|
247
274
|
*/
|
|
248
275
|
getProviderRoots(): string[] {
|
|
249
|
-
// Order matters: user customs >
|
|
250
|
-
// findProviderDirInternal walks this list in order
|
|
251
|
-
// dir containing the scripts/, so
|
|
252
|
-
// though loadAll() also reads it directly.
|
|
253
|
-
const
|
|
254
|
-
return [this.userDir,
|
|
276
|
+
// Order matters: user customs > external (3rd-party sources) > upstream
|
|
277
|
+
// (official auto-sync). findProviderDirInternal walks this list in order
|
|
278
|
+
// to locate the provider dir containing the scripts/, so external must
|
|
279
|
+
// be included here even though loadAll() also reads it directly.
|
|
280
|
+
const externalDir = path.join(os.homedir(), '.adhdev', 'external');
|
|
281
|
+
return [this.userDir, externalDir, this.upstreamDir];
|
|
255
282
|
}
|
|
256
283
|
|
|
257
284
|
getSourceConfig(): ProviderSourceConfigSnapshot {
|
|
@@ -343,16 +370,19 @@ export class ProviderLoader {
|
|
|
343
370
|
|
|
344
371
|
/**
|
|
345
372
|
* Load all providers (3-tier priority)
|
|
346
|
-
* 1.
|
|
347
|
-
* 2.
|
|
348
|
-
*
|
|
373
|
+
* 1. ~/.adhdev/providers/.upstream/ — official git, auto-synced
|
|
374
|
+
* 2. ~/.adhdev/external/ — 3rd-party git sources, user-added,
|
|
375
|
+
* bundled providers may include arbitrary JS (untrusted by default)
|
|
376
|
+
* 3. ~/.adhdev/providers/ (excluding .upstream) — user-authored customs,
|
|
377
|
+
* always wins
|
|
378
|
+
* Highest priority listed last (overwrites earlier loads).
|
|
349
379
|
* If .upstream/ is empty, call fetchLatest() before loadAll().
|
|
350
380
|
*/
|
|
351
381
|
loadAll(): void {
|
|
352
382
|
this.providers.clear();
|
|
353
383
|
this.providerAvailability.clear();
|
|
354
384
|
|
|
355
|
-
// 1. Load upstream (GitHub auto-download — primary source)
|
|
385
|
+
// 1. Load upstream (GitHub auto-download — primary official source)
|
|
356
386
|
let upstreamCount = 0;
|
|
357
387
|
if (!this.disableUpstream && fs.existsSync(this.upstreamDir)) {
|
|
358
388
|
upstreamCount = this.loadDir(this.upstreamDir);
|
|
@@ -363,15 +393,90 @@ export class ProviderLoader {
|
|
|
363
393
|
this.log('Upstream loading disabled (sourceMode=no-upstream)');
|
|
364
394
|
}
|
|
365
395
|
|
|
366
|
-
// 2. Load
|
|
367
|
-
//
|
|
368
|
-
// user
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
396
|
+
// 2. Load external providers from ~/.adhdev/external/<source-name>/
|
|
397
|
+
// (3rd-party git sources). Overrides upstream but is itself overridden
|
|
398
|
+
// by user customs in step 3.
|
|
399
|
+
//
|
|
400
|
+
// Each registered source is a separate subdirectory so two sources can
|
|
401
|
+
// both expose the same provider type without overwriting each other.
|
|
402
|
+
// When more than one source provides the same type, providers-active.json
|
|
403
|
+
// chooses the active one; without an explicit choice we deterministically
|
|
404
|
+
// pick the first in disk-walk order and log the ambiguity so the user
|
|
405
|
+
// can resolve it from the dashboard.
|
|
406
|
+
//
|
|
407
|
+
// Any non-spec manifest (tui block / overrides / scriptDir) coming from
|
|
408
|
+
// an external source runs JavaScript the daemon hasn't audited, so
|
|
409
|
+
// dashboards must surface an "untrusted source" badge before letting
|
|
410
|
+
// the user enable them.
|
|
411
|
+
const externalDir = path.join(os.homedir(), '.adhdev', 'external');
|
|
412
|
+
if (fs.existsSync(externalDir)) {
|
|
413
|
+
// Legacy layout (pre-source-namespace): manifests sit directly at
|
|
414
|
+
// external/<category>/<type>/. Detect by presence of category dirs at
|
|
415
|
+
// the root and migrate inline by treating the whole tree as a single
|
|
416
|
+
// implicit source. Loader behavior unchanged for legacy callers.
|
|
417
|
+
const rootEntries = (() => {
|
|
418
|
+
try { return fs.readdirSync(externalDir, { withFileTypes: true }); }
|
|
419
|
+
catch { return [] as fs.Dirent[]; }
|
|
420
|
+
})();
|
|
421
|
+
const KNOWN_CATEGORIES = new Set(['cli', 'ide', 'extension', 'acp']);
|
|
422
|
+
const looksLegacy = rootEntries.some(e => e.isDirectory() && KNOWN_CATEGORIES.has(e.name));
|
|
423
|
+
if (looksLegacy) {
|
|
424
|
+
// Tree shape predates per-source dirs — treat the whole thing as a
|
|
425
|
+
// single anonymous source so existing installs keep working until
|
|
426
|
+
// they're migrated to a real source registration.
|
|
427
|
+
const externalCount = this.loadDir(externalDir);
|
|
428
|
+
if (externalCount > 0) {
|
|
429
|
+
this.log(`Loaded ${externalCount} external providers (legacy unnamed source)`);
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
// New layout: external/<source-name>/<category>/<type>/…
|
|
433
|
+
const {
|
|
434
|
+
loadProvidersActive,
|
|
435
|
+
resolveActiveSource,
|
|
436
|
+
} = require('./external-sources.js') as typeof import('./external-sources.js');
|
|
437
|
+
const activeFile = loadProvidersActive();
|
|
438
|
+
let totalLoaded = 0;
|
|
439
|
+
const ambiguousTypes: { type: string; chosen: string; candidates: string[] }[] = [];
|
|
440
|
+
// Per-source load, then filter by active-selection: for each type
|
|
441
|
+
// present in more than one source, only the active source's copy
|
|
442
|
+
// is left in this.providers.
|
|
443
|
+
for (const sourceEntry of rootEntries) {
|
|
444
|
+
if (!sourceEntry.isDirectory()) continue;
|
|
445
|
+
const sourceDir = path.join(externalDir, sourceEntry.name);
|
|
446
|
+
const sourceLoaded = this.loadDir(sourceDir);
|
|
447
|
+
if (sourceLoaded > 0) {
|
|
448
|
+
totalLoaded += sourceLoaded;
|
|
449
|
+
this.log(`Loaded ${sourceLoaded} providers from external source "${sourceEntry.name}"`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// Resolve ambiguities — when the same type came from multiple
|
|
453
|
+
// sources, the last load wins by default. Replay with the active
|
|
454
|
+
// selection so the user-chosen source ends up winning.
|
|
455
|
+
for (const [type] of this.providers) {
|
|
456
|
+
const prov = this.providers.get(type);
|
|
457
|
+
if (!prov) continue;
|
|
458
|
+
const resolved = resolveActiveSource(prov.category, type, activeFile);
|
|
459
|
+
if (resolved.candidates.length <= 1) continue;
|
|
460
|
+
if (resolved.ambiguous) {
|
|
461
|
+
ambiguousTypes.push({ type, chosen: resolved.source ?? '?', candidates: resolved.candidates });
|
|
462
|
+
}
|
|
463
|
+
if (resolved.source && resolved.source !== '?') {
|
|
464
|
+
const sourceDir = path.join(externalDir, resolved.source);
|
|
465
|
+
// Reload only this source's copy of the conflicting type so it
|
|
466
|
+
// overwrites whatever else won the initial pass.
|
|
467
|
+
const reloadCount = this.loadDir(sourceDir);
|
|
468
|
+
// reloadCount is a sanity check — we expect ≥1
|
|
469
|
+
if (reloadCount === 0) {
|
|
470
|
+
this.log(`Active source "${resolved.source}" no longer provides ${type}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (totalLoaded > 0) {
|
|
475
|
+
this.log(`Loaded ${totalLoaded} external providers (3rd-party sources)`);
|
|
476
|
+
}
|
|
477
|
+
for (const a of ambiguousTypes) {
|
|
478
|
+
this.log(`Ambiguous provider "${a.type}" — provided by [${a.candidates.join(', ')}], defaulted to "${a.chosen}". Set the active source from the dashboard to silence this warning.`);
|
|
479
|
+
}
|
|
375
480
|
}
|
|
376
481
|
}
|
|
377
482
|
|
|
@@ -2132,14 +2237,40 @@ export class ProviderLoader {
|
|
|
2132
2237
|
}
|
|
2133
2238
|
}
|
|
2134
2239
|
|
|
2240
|
+
// Classify trust based on which on-disk layer this manifest
|
|
2241
|
+
// came from + whether it ships JavaScript hooks. The dashboard
|
|
2242
|
+
// uses this to render trust badges; non-spec external manifests
|
|
2243
|
+
// need an explicit user confirm before activation.
|
|
2244
|
+
const externalDirAbs = path.join(os.homedir(), '.adhdev', 'external');
|
|
2245
|
+
const layer: 'user' | 'upstream' | 'external' = d.startsWith(externalDirAbs)
|
|
2246
|
+
? 'external'
|
|
2247
|
+
: (d.startsWith(this.userDir) && !d.includes('.upstream') ? 'user' : 'upstream');
|
|
2248
|
+
try {
|
|
2249
|
+
const { inspectManifestShape, classifyTrust } =
|
|
2250
|
+
require('./provider-trust.js') as typeof import('./provider-trust.js');
|
|
2251
|
+
const shape = inspectManifestShape(mod as Record<string, unknown>);
|
|
2252
|
+
const trust = classifyTrust(layer, shape);
|
|
2253
|
+
(normalizedProvider as any)._sourceLayer = layer;
|
|
2254
|
+
(normalizedProvider as any)._sourceTrust = trust;
|
|
2255
|
+
(normalizedProvider as any)._manifestShape = shape;
|
|
2256
|
+
// For external-namespaced layouts (external/<source>/…) record
|
|
2257
|
+
// which source the manifest came from so dashboards can name
|
|
2258
|
+
// it in the trust badge.
|
|
2259
|
+
if (layer === 'external') {
|
|
2260
|
+
const rel = path.relative(externalDirAbs, d);
|
|
2261
|
+
const firstSeg = rel.split(path.sep)[0];
|
|
2262
|
+
if (firstSeg && firstSeg !== '..') (normalizedProvider as any)._sourceName = firstSeg;
|
|
2263
|
+
}
|
|
2264
|
+
} catch { /* best-effort — trust is enrichment, not gating */ }
|
|
2265
|
+
|
|
2135
2266
|
const existed = this.providers.has(normalizedProvider.type);
|
|
2136
2267
|
this.providers.set(normalizedProvider.type, normalizedProvider);
|
|
2137
2268
|
count++;
|
|
2138
|
-
|
|
2139
|
-
const source = d.startsWith(this.userDir) && !d.includes('.upstream')
|
|
2140
|
-
? 'user' : 'upstream';
|
|
2269
|
+
const source = (normalizedProvider as any)._sourceLayer ?? 'upstream';
|
|
2141
2270
|
const overrideWarning = existed && source === 'user' ? ' ⚠ OVERRIDES upstream' : '';
|
|
2142
|
-
|
|
2271
|
+
const sourceName = (normalizedProvider as any)._sourceName;
|
|
2272
|
+
const sourceLabel = sourceName ? `${source}/${sourceName}` : source;
|
|
2273
|
+
this.log(` ${existed ? '🔄' : '✅'} ${normalizedProvider.type} (${normalizedProvider.category}) — ${normalizedProvider.name} [${sourceLabel}]${overrideWarning}`);
|
|
2143
2274
|
}
|
|
2144
2275
|
} catch (e) {
|
|
2145
2276
|
this.log(`⚠ Failed to load ${jsonPath}: ${(e as Error).message}`);
|
|
@@ -2151,6 +2282,11 @@ export class ProviderLoader {
|
|
|
2151
2282
|
for (const entry of entries) {
|
|
2152
2283
|
if (!entry.isDirectory()) continue;
|
|
2153
2284
|
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
|
|
2285
|
+
// `examples/` is a documentation / scaffold tree (e.g. stub-cli),
|
|
2286
|
+
// not a real provider source. SDK authors copy from here when
|
|
2287
|
+
// writing a new provider; daemon-core tests reference the
|
|
2288
|
+
// manifest by path. Keep it off the dashboard's provider list.
|
|
2289
|
+
if (d === dir && entry.name === 'examples') continue;
|
|
2154
2290
|
if (excludeDirs && d === dir && excludeDirs.includes(entry.name)) continue;
|
|
2155
2291
|
scan(path.join(d, entry.name));
|
|
2156
2292
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider trust classification.
|
|
3
|
+
*
|
|
4
|
+
* Each loaded provider gets a `trust` tag that the daemon and dashboard
|
|
5
|
+
* use to decide what to surface and what to gate behind a confirmation
|
|
6
|
+
* prompt. Trust is a function of *where* the provider came from
|
|
7
|
+
* (upstream / external / userDir) and the *shape of the manifest*
|
|
8
|
+
* (spec-only vs. carries JavaScript hooks).
|
|
9
|
+
*
|
|
10
|
+
* Rationale:
|
|
11
|
+
* - Spec-only manifests are static declarative configuration; the
|
|
12
|
+
* daemon's spec adapter walks the spec but never executes code
|
|
13
|
+
* authored by the provider.
|
|
14
|
+
* - Non-spec manifests ship JavaScript (tui-block builders consumed
|
|
15
|
+
* by the SDK, scriptDir overrides, or explicit `overrides.* .path`
|
|
16
|
+
* bindings). That code runs inside the daemon process with the
|
|
17
|
+
* same privileges as adhdev itself.
|
|
18
|
+
*
|
|
19
|
+
* Source × shape decides the trust label:
|
|
20
|
+
*
|
|
21
|
+
* layer | spec-only | non-spec
|
|
22
|
+
* ---------|---------------------|-----------------------
|
|
23
|
+
* user | user-custom | user-custom
|
|
24
|
+
* upstream | trusted | trusted-with-scripts
|
|
25
|
+
* external | external-safe | external-untrusted
|
|
26
|
+
*
|
|
27
|
+
* Dashboards should always show a badge for `trusted-with-scripts`
|
|
28
|
+
* (info — runs official JS), `external-safe` (info — declarative only,
|
|
29
|
+
* but 3rd-party), and `external-untrusted` (warning — runs JS from a
|
|
30
|
+
* 3rd-party). Activation of `external-untrusted` should require an
|
|
31
|
+
* explicit confirmation step that names the source URL.
|
|
32
|
+
*/
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
export type ProviderTrust =
|
|
36
|
+
| 'user-custom'
|
|
37
|
+
| 'trusted'
|
|
38
|
+
| 'trusted-with-scripts'
|
|
39
|
+
| 'external-safe'
|
|
40
|
+
| 'external-untrusted';
|
|
41
|
+
|
|
42
|
+
export type ProviderLayer = 'user' | 'upstream' | 'external';
|
|
43
|
+
|
|
44
|
+
export interface ProviderManifestShape {
|
|
45
|
+
/** Has a `tui` block — SDK builders consume it as code paths. */
|
|
46
|
+
hasTui: boolean;
|
|
47
|
+
/** Has a non-empty `overrides` object — JS override paths. */
|
|
48
|
+
hasOverrides: boolean;
|
|
49
|
+
/** compatibility[].scriptDir or defaultScriptDir is set — JS scripts dir. */
|
|
50
|
+
hasScriptDir: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Inspect a manifest to decide whether it ships JavaScript hooks.
|
|
55
|
+
* Cheap; pure; safe to call on every provider load.
|
|
56
|
+
*/
|
|
57
|
+
export function inspectManifestShape(manifest: Record<string, unknown>): ProviderManifestShape {
|
|
58
|
+
const hasTui = !!manifest.tui && typeof manifest.tui === 'object'
|
|
59
|
+
&& Object.keys(manifest.tui as Record<string, unknown>).length > 0;
|
|
60
|
+
const hasOverrides = !!manifest.overrides && typeof manifest.overrides === 'object'
|
|
61
|
+
&& !Array.isArray(manifest.overrides)
|
|
62
|
+
&& Object.keys(manifest.overrides as Record<string, unknown>).length > 0;
|
|
63
|
+
const compat = Array.isArray(manifest.compatibility) ? manifest.compatibility : [];
|
|
64
|
+
const compatHasScriptDir = compat.some((entry: any) => typeof entry?.scriptDir === 'string');
|
|
65
|
+
const hasScriptDir = compatHasScriptDir || typeof manifest.defaultScriptDir === 'string';
|
|
66
|
+
return { hasTui, hasOverrides, hasScriptDir };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Classify trust given the layer the provider was loaded from + the
|
|
71
|
+
* manifest's JS-hook footprint.
|
|
72
|
+
*/
|
|
73
|
+
export function classifyTrust(
|
|
74
|
+
layer: ProviderLayer,
|
|
75
|
+
shape: ProviderManifestShape,
|
|
76
|
+
): ProviderTrust {
|
|
77
|
+
const isSpecOnly = !shape.hasTui && !shape.hasOverrides && !shape.hasScriptDir;
|
|
78
|
+
switch (layer) {
|
|
79
|
+
case 'user':
|
|
80
|
+
return 'user-custom';
|
|
81
|
+
case 'upstream':
|
|
82
|
+
return isSpecOnly ? 'trusted' : 'trusted-with-scripts';
|
|
83
|
+
case 'external':
|
|
84
|
+
return isSpecOnly ? 'external-safe' : 'external-untrusted';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Returns true when activation should require an explicit user confirm.
|
|
90
|
+
* Today only `external-untrusted` qualifies; future trust levels may
|
|
91
|
+
* fold in here.
|
|
92
|
+
*/
|
|
93
|
+
export function requiresConfirmation(trust: ProviderTrust): boolean {
|
|
94
|
+
return trust === 'external-untrusted';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Render a short human-readable rationale for the trust tag. Used by
|
|
99
|
+
* the dashboard's confirmation modal and the provider catalog tooltip.
|
|
100
|
+
*/
|
|
101
|
+
export function describeTrust(trust: ProviderTrust): string {
|
|
102
|
+
switch (trust) {
|
|
103
|
+
case 'user-custom':
|
|
104
|
+
return 'Hand-authored in ~/.adhdev/providers/. Runs your own code.';
|
|
105
|
+
case 'trusted':
|
|
106
|
+
return 'Official, declarative-only manifest from the ADHDev registry.';
|
|
107
|
+
case 'trusted-with-scripts':
|
|
108
|
+
return 'Official manifest from the ADHDev registry. Ships JavaScript hooks executed by the daemon.';
|
|
109
|
+
case 'external-safe':
|
|
110
|
+
return 'Manifest from a 3rd-party git source you added. Declarative-only — the daemon never runs JS from this source.';
|
|
111
|
+
case 'external-untrusted':
|
|
112
|
+
return 'Manifest from a 3rd-party git source you added. Ships JavaScript that the daemon will execute. Treat as untrusted code — review the source before enabling.';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -143,7 +143,7 @@ export const V1_PRIMITIVE_CATALOG = Object.freeze({
|
|
|
143
143
|
],
|
|
144
144
|
} as const);
|
|
145
145
|
|
|
146
|
-
/** Aggregate flat list — for
|
|
146
|
+
/** Aggregate flat list — for provider catalog endpoints. */
|
|
147
147
|
export const V1_ALL_PRIMITIVES: ReadonlyArray<string> = Object.freeze(
|
|
148
148
|
Object.values(V1_PRIMITIVE_CATALOG).flat(),
|
|
149
149
|
);
|
|
@@ -344,7 +344,7 @@ function canonicalize(p: string): string | null {
|
|
|
344
344
|
? nodeFs.realpathSync.native(resolved)
|
|
345
345
|
: nodeFs.realpathSync(resolved);
|
|
346
346
|
} catch {
|
|
347
|
-
// Root may not exist yet (e.g.
|
|
347
|
+
// Root may not exist yet (e.g. external dir created lazily);
|
|
348
348
|
// we still register the resolved path so future requires from
|
|
349
349
|
// inside are gated once the directory shows up.
|
|
350
350
|
return resolved;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* offending field without guessing.
|
|
10
10
|
*
|
|
11
11
|
* Validation lives in the SDK layer (not in provider-loader) so dashboards,
|
|
12
|
-
* registry workers, and the
|
|
12
|
+
* registry workers, and the provider publish flow can all reuse the
|
|
13
13
|
* same code path and produce identical error messages.
|
|
14
14
|
*/
|
|
15
15
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Static taint analyzer for extended-tier override JS.
|
|
3
3
|
*
|
|
4
4
|
* Goal: classify the override JS shipped with an extended-tier provider into
|
|
5
|
-
* one of three risk tiers, so the
|
|
5
|
+
* one of three risk tiers, so the dashboard + daemon trust prompt can
|
|
6
6
|
* surface accurate language to the operator instead of a single generic
|
|
7
7
|
* "this provider contains JS" warning.
|
|
8
8
|
*
|
package/src/shared-types.ts
CHANGED
|
@@ -525,7 +525,39 @@ export interface AvailableProviderInfo {
|
|
|
525
525
|
lastVerification?: MachineProviderCheckResult;
|
|
526
526
|
/** Provider-declared Repo Mesh coordinator/MCP behavior. */
|
|
527
527
|
meshCoordinator?: ProviderMeshCoordinatorConfig;
|
|
528
|
-
|
|
528
|
+
/**
|
|
529
|
+
* Provider trust classification — derived from the on-disk layer the
|
|
530
|
+
* manifest came from and the shape of the manifest. Dashboards use
|
|
531
|
+
* this to render a trust badge and gate activation of
|
|
532
|
+
* `external-untrusted` providers behind a confirm modal.
|
|
533
|
+
*/
|
|
534
|
+
trust?: ProviderTrust;
|
|
535
|
+
/** Daemon-side human-readable description of the trust value. */
|
|
536
|
+
trustDescription?: string;
|
|
537
|
+
/** True when activation needs a user-side confirmation step. */
|
|
538
|
+
requiresConfirmation?: boolean;
|
|
539
|
+
/** Which on-disk layer the manifest lives in. */
|
|
540
|
+
sourceLayer?: 'user' | 'upstream' | 'external';
|
|
541
|
+
/** For external providers, the source-name namespace it came from. */
|
|
542
|
+
sourceName?: string | null;
|
|
543
|
+
/** Manifest-declared version, e.g. "1.2.1". */
|
|
544
|
+
providerVersion?: string;
|
|
545
|
+
/** Underlying executable name (CLI/binary providers). */
|
|
546
|
+
binary?: string;
|
|
547
|
+
/** Lifecycle label from the manifest: "Stable", "Beta", … */
|
|
548
|
+
status?: string;
|
|
549
|
+
/** One-line provider description from the manifest. */
|
|
550
|
+
details?: string;
|
|
551
|
+
/** Manifest-declared links: homepage, docs, repo, … */
|
|
552
|
+
links?: Record<string, string>;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export type ProviderTrust =
|
|
556
|
+
| 'user-custom'
|
|
557
|
+
| 'trusted'
|
|
558
|
+
| 'trusted-with-scripts'
|
|
559
|
+
| 'external-safe'
|
|
560
|
+
| 'external-untrusted';
|
|
529
561
|
|
|
530
562
|
export interface MachineProviderCheckResult {
|
|
531
563
|
ok: boolean;
|