@aragon-agent/cli 0.4.3
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/CHANGELOG.md +292 -0
- package/README.md +558 -0
- package/dist/agent/coalesce.d.ts +14 -0
- package/dist/agent/coalesce.js +41 -0
- package/dist/agent/controller.d.ts +132 -0
- package/dist/agent/controller.js +332 -0
- package/dist/agent/headless.d.ts +22 -0
- package/dist/agent/headless.js +74 -0
- package/dist/agent/reducer.d.ts +181 -0
- package/dist/agent/reducer.js +420 -0
- package/dist/agent/system-prompt.d.ts +30 -0
- package/dist/agent/system-prompt.js +58 -0
- package/dist/agent/usage.d.ts +13 -0
- package/dist/agent/usage.js +41 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +482 -0
- package/dist/commands/builtins.d.ts +16 -0
- package/dist/commands/builtins.js +223 -0
- package/dist/commands/registry.d.ts +48 -0
- package/dist/commands/registry.js +45 -0
- package/dist/commands/skills.d.ts +22 -0
- package/dist/commands/skills.js +625 -0
- package/dist/config/app-paths.d.ts +11 -0
- package/dist/config/app-paths.js +16 -0
- package/dist/config/env.d.ts +28 -0
- package/dist/config/env.js +92 -0
- package/dist/config/load.d.ts +48 -0
- package/dist/config/load.js +158 -0
- package/dist/config/migrate-legacy-state.d.ts +78 -0
- package/dist/config/migrate-legacy-state.js +181 -0
- package/dist/config/schema.d.ts +249 -0
- package/dist/config/schema.js +247 -0
- package/dist/config/store.d.ts +44 -0
- package/dist/config/store.js +126 -0
- package/dist/input/keymap.d.ts +20 -0
- package/dist/input/keymap.js +85 -0
- package/dist/session/persist.d.ts +24 -0
- package/dist/session/persist.js +51 -0
- package/dist/skills/archive.d.ts +41 -0
- package/dist/skills/archive.js +152 -0
- package/dist/skills/cli-commands.d.ts +52 -0
- package/dist/skills/cli-commands.js +502 -0
- package/dist/skills/fetch-source.d.ts +104 -0
- package/dist/skills/fetch-source.js +448 -0
- package/dist/skills/installer.d.ts +117 -0
- package/dist/skills/installer.js +414 -0
- package/dist/skills/lock.d.ts +75 -0
- package/dist/skills/lock.js +223 -0
- package/dist/skills/manifest.d.ts +47 -0
- package/dist/skills/manifest.js +95 -0
- package/dist/skills/node-host.d.ts +23 -0
- package/dist/skills/node-host.js +59 -0
- package/dist/skills/paths.d.ts +61 -0
- package/dist/skills/paths.js +107 -0
- package/dist/skills/service.d.ts +256 -0
- package/dist/skills/service.js +730 -0
- package/dist/skills/tools.d.ts +35 -0
- package/dist/skills/tools.js +164 -0
- package/dist/skills/updater.d.ts +101 -0
- package/dist/skills/updater.js +330 -0
- package/dist/skills/usage.d.ts +72 -0
- package/dist/skills/usage.js +216 -0
- package/dist/tools/bash-tool.d.ts +12 -0
- package/dist/tools/bash-tool.js +131 -0
- package/dist/tools/diff.d.ts +15 -0
- package/dist/tools/diff.js +50 -0
- package/dist/tools/fs-tools.d.ts +16 -0
- package/dist/tools/fs-tools.js +215 -0
- package/dist/tools/index.d.ts +93 -0
- package/dist/tools/index.js +157 -0
- package/dist/tools/search-tools.d.ts +11 -0
- package/dist/tools/search-tools.js +225 -0
- package/dist/ui/App.d.ts +24 -0
- package/dist/ui/App.js +522 -0
- package/dist/ui/AutocompletePopup.d.ts +21 -0
- package/dist/ui/AutocompletePopup.js +19 -0
- package/dist/ui/Composer.d.ts +40 -0
- package/dist/ui/Composer.js +61 -0
- package/dist/ui/Header.d.ts +30 -0
- package/dist/ui/Header.js +25 -0
- package/dist/ui/Logo.d.ts +50 -0
- package/dist/ui/Logo.js +85 -0
- package/dist/ui/Markdown.d.ts +23 -0
- package/dist/ui/Markdown.js +122 -0
- package/dist/ui/PromptInput.d.ts +81 -0
- package/dist/ui/PromptInput.js +383 -0
- package/dist/ui/SessionOpener.d.ts +30 -0
- package/dist/ui/SessionOpener.js +12 -0
- package/dist/ui/StatusBar.d.ts +39 -0
- package/dist/ui/StatusBar.js +43 -0
- package/dist/ui/ToastStack.d.ts +24 -0
- package/dist/ui/ToastStack.js +38 -0
- package/dist/ui/Transcript.d.ts +62 -0
- package/dist/ui/Transcript.js +128 -0
- package/dist/ui/capabilities.d.ts +25 -0
- package/dist/ui/capabilities.js +59 -0
- package/dist/ui/console-bridge.d.ts +19 -0
- package/dist/ui/console-bridge.js +52 -0
- package/dist/ui/density.d.ts +26 -0
- package/dist/ui/density.js +34 -0
- package/dist/ui/entries/AssistantEntry.d.ts +23 -0
- package/dist/ui/entries/AssistantEntry.js +13 -0
- package/dist/ui/entries/EntryFrame.d.ts +23 -0
- package/dist/ui/entries/EntryFrame.js +6 -0
- package/dist/ui/entries/ToolCard.d.ts +38 -0
- package/dist/ui/entries/ToolCard.js +72 -0
- package/dist/ui/entries/ToolPreview.d.ts +57 -0
- package/dist/ui/entries/ToolPreview.js +144 -0
- package/dist/ui/entries/UserEntry.d.ts +14 -0
- package/dist/ui/entries/UserEntry.js +5 -0
- package/dist/ui/exit-snapshot.d.ts +27 -0
- package/dist/ui/exit-snapshot.js +26 -0
- package/dist/ui/gauge.d.ts +24 -0
- package/dist/ui/gauge.js +36 -0
- package/dist/ui/glyphs.d.ts +87 -0
- package/dist/ui/glyphs.js +127 -0
- package/dist/ui/gradient.d.ts +11 -0
- package/dist/ui/gradient.js +50 -0
- package/dist/ui/layout/AppShell.d.ts +24 -0
- package/dist/ui/layout/AppShell.js +9 -0
- package/dist/ui/layout/Gutter.d.ts +67 -0
- package/dist/ui/layout/Gutter.js +42 -0
- package/dist/ui/layout/OverlayFrame.d.ts +62 -0
- package/dist/ui/layout/OverlayFrame.js +92 -0
- package/dist/ui/layout/ScrollViewport.d.ts +33 -0
- package/dist/ui/layout/ScrollViewport.js +71 -0
- package/dist/ui/layout/budget.d.ts +42 -0
- package/dist/ui/layout/budget.js +47 -0
- package/dist/ui/layout/frame.d.ts +58 -0
- package/dist/ui/layout/frame.js +75 -0
- package/dist/ui/layout/overlay-window.d.ts +43 -0
- package/dist/ui/layout/overlay-window.js +51 -0
- package/dist/ui/layout/scroll.d.ts +28 -0
- package/dist/ui/layout/scroll.js +76 -0
- package/dist/ui/layout/useTerminalSize.d.ts +13 -0
- package/dist/ui/layout/useTerminalSize.js +50 -0
- package/dist/ui/markdown-blocks.d.ts +62 -0
- package/dist/ui/markdown-blocks.js +120 -0
- package/dist/ui/overlays/ConfirmDialog.d.ts +30 -0
- package/dist/ui/overlays/ConfirmDialog.js +25 -0
- package/dist/ui/overlays/HelpOverlay.d.ts +17 -0
- package/dist/ui/overlays/HelpOverlay.js +60 -0
- package/dist/ui/overlays/ModelPicker.d.ts +29 -0
- package/dist/ui/overlays/ModelPicker.js +29 -0
- package/dist/ui/overlays/SettingsScreen.d.ts +37 -0
- package/dist/ui/overlays/SettingsScreen.js +106 -0
- package/dist/ui/palettes.d.ts +79 -0
- package/dist/ui/palettes.js +115 -0
- package/dist/ui/screen.d.ts +27 -0
- package/dist/ui/screen.js +57 -0
- package/dist/ui/theme.d.ts +78 -0
- package/dist/ui/theme.js +127 -0
- package/dist/ui/transcript-text.d.ts +27 -0
- package/dist/ui/transcript-text.js +110 -0
- package/package.json +73 -0
- package/skills/skill-creator/SKILL.md +96 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install-source identification and fetching (spec §8.2 / §9.2).
|
|
3
|
+
*
|
|
4
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* `skill_install.source` IS A MODEL PARAMETER. Treat it as hostile input.
|
|
6
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
7
|
+
*
|
|
8
|
+
* Four rules are MUST (§8.2.1 / D20). Breaking any one of them is a shell or
|
|
9
|
+
* argv injection, not a style problem:
|
|
10
|
+
*
|
|
11
|
+
* 1. External processes are started with `execFile(cmd, argv[], {shell:false})`
|
|
12
|
+
* only. No `exec()`, no `execSync()`, no `shell: true`, no string
|
|
13
|
+
* concatenation of a command line. `tools/bash-tool.ts` DOES use
|
|
14
|
+
* `shell: true` — that is the bash tool's deliberate purpose and is NOT a
|
|
15
|
+
* precedent for this module. Copy `builtins.ts::copyToClipboard` instead.
|
|
16
|
+
*
|
|
17
|
+
* 2. Validate BEFORE building argv. `resolveSource()` is pure and spawns
|
|
18
|
+
* nothing; anything that fails here never reaches a subprocess.
|
|
19
|
+
*
|
|
20
|
+
* 3. Reject a leading `-` on every model-controlled fragment. This matters
|
|
21
|
+
* even with a pure argv array: `git clone --branch --upload-pack=calc.exe`
|
|
22
|
+
* makes git execute an arbitrary program. `shell: false` does not help.
|
|
23
|
+
* The whitelists below already force an alphanumeric first character; the
|
|
24
|
+
* explicit check is a second, separately-tested statement of intent.
|
|
25
|
+
*
|
|
26
|
+
* 4. Never let git prompt. A private repo would otherwise hang a headless
|
|
27
|
+
* install forever waiting on a credential prompt nobody can see.
|
|
28
|
+
*/
|
|
29
|
+
import { execFile } from 'node:child_process';
|
|
30
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import process from 'node:process';
|
|
33
|
+
import { promisify } from 'node:util';
|
|
34
|
+
import { extractZip, ARCHIVE_MAX_DOWNLOAD_BYTES } from './archive.js';
|
|
35
|
+
import { isDirectory } from './paths.js';
|
|
36
|
+
const execFileAsync = promisify(execFile);
|
|
37
|
+
export const HTTP_TIMEOUT_MS = 15_000;
|
|
38
|
+
export const GIT_TIMEOUT_MS = 120_000;
|
|
39
|
+
export const MAX_REDIRECTS = 3;
|
|
40
|
+
export const SKILL_MD_DOWNLOAD_MAX_BYTES = 512 * 1024;
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Whitelists (§8.2.1 rule 2)
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
const OWNER_REPO = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
45
|
+
const REF = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/;
|
|
46
|
+
const SUBDIR = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/;
|
|
47
|
+
const COMMIT_SHA = /^[0-9a-f]{7,40}$/;
|
|
48
|
+
function rejectsDashPrefix(value) {
|
|
49
|
+
return value.startsWith('-');
|
|
50
|
+
}
|
|
51
|
+
function validOwnerOrRepo(value) {
|
|
52
|
+
if (value === '.' || value === '..')
|
|
53
|
+
return false;
|
|
54
|
+
if (rejectsDashPrefix(value))
|
|
55
|
+
return false;
|
|
56
|
+
return OWNER_REPO.test(value);
|
|
57
|
+
}
|
|
58
|
+
function validRef(value) {
|
|
59
|
+
if (rejectsDashPrefix(value))
|
|
60
|
+
return false;
|
|
61
|
+
if (value.includes('..'))
|
|
62
|
+
return false;
|
|
63
|
+
if (value.endsWith('/') || value.endsWith('.'))
|
|
64
|
+
return false;
|
|
65
|
+
return REF.test(value);
|
|
66
|
+
}
|
|
67
|
+
function validSubdir(value) {
|
|
68
|
+
if (rejectsDashPrefix(value))
|
|
69
|
+
return false;
|
|
70
|
+
if (value.includes('..'))
|
|
71
|
+
return false;
|
|
72
|
+
if (value.startsWith('/'))
|
|
73
|
+
return false;
|
|
74
|
+
return SUBDIR.test(value);
|
|
75
|
+
}
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Host policy (§9.2 — SSRF guard)
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
|
80
|
+
/** `https:` only, hostname on the allowlist, never an IP literal or loopback. */
|
|
81
|
+
export function checkUrlAllowed(raw, allowedHosts) {
|
|
82
|
+
let url;
|
|
83
|
+
try {
|
|
84
|
+
url = new URL(raw);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return { ok: false, error: `not a valid URL: ${raw}` };
|
|
88
|
+
}
|
|
89
|
+
if (url.protocol !== 'https:') {
|
|
90
|
+
return { ok: false, error: `only https:// sources are allowed (got ${url.protocol}//)` };
|
|
91
|
+
}
|
|
92
|
+
const host = url.hostname.toLowerCase();
|
|
93
|
+
if (host === 'localhost' || host.endsWith('.localhost') || IPV4.test(host) || host.includes(':')) {
|
|
94
|
+
return { ok: false, error: `IP literals and localhost are not allowed: ${host}` };
|
|
95
|
+
}
|
|
96
|
+
if (!allowedHosts.map((h) => h.toLowerCase()).includes(host)) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
error: `host "${host}" is not in skills.allowedHosts (${allowedHosts.join(', ')})`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return { ok: true, url };
|
|
103
|
+
}
|
|
104
|
+
const UNSUPPORTED = (source) => ({
|
|
105
|
+
ok: false,
|
|
106
|
+
error: `Unsupported skill source: ${source}`,
|
|
107
|
+
});
|
|
108
|
+
const GITHUB_SHORTHAND = /^github:(.+)$/;
|
|
109
|
+
const GIT_HOSTS = new Set(['github.com', 'gitlab.com']);
|
|
110
|
+
/**
|
|
111
|
+
* Classify and validate an install source.
|
|
112
|
+
*
|
|
113
|
+
* Every rejection path returns before any subprocess or socket exists — that
|
|
114
|
+
* property is what AC-15 asserts (mocked `execFile`, call count 0).
|
|
115
|
+
*/
|
|
116
|
+
export function resolveSource(source, allowedHosts) {
|
|
117
|
+
const trimmed = source.trim();
|
|
118
|
+
if (trimmed.length === 0)
|
|
119
|
+
return UNSUPPORTED(source);
|
|
120
|
+
// 1. `github:owner/repo[/subdir][#ref]`
|
|
121
|
+
const shorthand = GITHUB_SHORTHAND.exec(trimmed);
|
|
122
|
+
if (shorthand)
|
|
123
|
+
return resolveGithubShorthand(shorthand[1], trimmed, allowedHosts);
|
|
124
|
+
// 2. Local paths — checked before URLs so a Windows path is never a URL.
|
|
125
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && !trimmed.startsWith('git+')) {
|
|
126
|
+
if (isDirectory(trimmed))
|
|
127
|
+
return { ok: true, spec: { kind: 'local-dir', path: trimmed } };
|
|
128
|
+
if (/\.md$/i.test(trimmed))
|
|
129
|
+
return { ok: true, spec: { kind: 'local-file', path: trimmed } };
|
|
130
|
+
return UNSUPPORTED(source);
|
|
131
|
+
}
|
|
132
|
+
// 3. Explicit git URL.
|
|
133
|
+
const isGitPlus = trimmed.startsWith('git+');
|
|
134
|
+
const bare = isGitPlus ? trimmed.slice(4) : trimmed;
|
|
135
|
+
const [beforeHash, afterHash] = splitOnce(bare, '#');
|
|
136
|
+
const ref = afterHash ?? null;
|
|
137
|
+
if (ref !== null && !validRef(ref)) {
|
|
138
|
+
return { ok: false, error: `Unsupported skill source: invalid ref "${ref}"` };
|
|
139
|
+
}
|
|
140
|
+
const checked = checkUrlAllowed(beforeHash, allowedHosts);
|
|
141
|
+
if (!checked.ok)
|
|
142
|
+
return { ok: false, error: `Unsupported skill source: ${checked.error}` };
|
|
143
|
+
const url = checked.url;
|
|
144
|
+
const segments = url.pathname.split('/').filter((s) => s.length > 0);
|
|
145
|
+
const looksGit = isGitPlus ||
|
|
146
|
+
url.pathname.endsWith('.git') ||
|
|
147
|
+
(GIT_HOSTS.has(url.hostname.toLowerCase()) &&
|
|
148
|
+
segments.length >= 2 &&
|
|
149
|
+
!/\.(md|zip|tar\.gz|tgz)$/i.test(url.pathname));
|
|
150
|
+
if (looksGit) {
|
|
151
|
+
if (segments.length < 2)
|
|
152
|
+
return UNSUPPORTED(source);
|
|
153
|
+
const owner = segments[0];
|
|
154
|
+
const repo = segments[1].replace(/\.git$/, '');
|
|
155
|
+
if (!validOwnerOrRepo(owner) || !validOwnerOrRepo(repo)) {
|
|
156
|
+
return { ok: false, error: `Unsupported skill source: invalid owner/repo in ${source}` };
|
|
157
|
+
}
|
|
158
|
+
const subdir = segments.slice(2).join('/');
|
|
159
|
+
if (subdir.length > 0 && !validSubdir(subdir)) {
|
|
160
|
+
return { ok: false, error: `Unsupported skill source: invalid subdirectory "${subdir}"` };
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
spec: {
|
|
165
|
+
kind: 'git',
|
|
166
|
+
url: `https://${url.hostname}/${owner}/${repo}.git`,
|
|
167
|
+
ref,
|
|
168
|
+
subdir: subdir.length > 0 ? subdir : null,
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (/\.md$/i.test(url.pathname))
|
|
173
|
+
return { ok: true, spec: { kind: 'https-md', url: url.toString() } };
|
|
174
|
+
if (/\.zip$/i.test(url.pathname))
|
|
175
|
+
return { ok: true, spec: { kind: 'https-zip', url: url.toString() } };
|
|
176
|
+
return UNSUPPORTED(source);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Expand `github:owner/repo[/subdir][#ref]`.
|
|
180
|
+
*
|
|
181
|
+
* The synthesized URL goes through the SAME host allowlist as an explicit one.
|
|
182
|
+
* The shorthand is sugar, not a second policy: without this check a user who
|
|
183
|
+
* narrowed `skills.allowedHosts` to an internal mirror would still see the model
|
|
184
|
+
* reach github.com, and the setting they trusted to stop that would be silently
|
|
185
|
+
* inert for the shortest, most likely-to-be-used spelling.
|
|
186
|
+
*/
|
|
187
|
+
function resolveGithubShorthand(rest, original, allowedHosts) {
|
|
188
|
+
const [pathPart, refPart] = splitOnce(rest, '#');
|
|
189
|
+
const ref = refPart ?? null;
|
|
190
|
+
if (ref !== null && !validRef(ref)) {
|
|
191
|
+
return { ok: false, error: `Unsupported skill source: invalid ref "${ref}"` };
|
|
192
|
+
}
|
|
193
|
+
const segments = pathPart.split('/').filter((s) => s.length > 0);
|
|
194
|
+
if (segments.length < 2)
|
|
195
|
+
return UNSUPPORTED(original);
|
|
196
|
+
const owner = segments[0];
|
|
197
|
+
const repo = segments[1].replace(/\.git$/, '');
|
|
198
|
+
if (!validOwnerOrRepo(owner) || !validOwnerOrRepo(repo)) {
|
|
199
|
+
return { ok: false, error: `Unsupported skill source: invalid owner/repo in ${original}` };
|
|
200
|
+
}
|
|
201
|
+
const subdir = segments.slice(2).join('/');
|
|
202
|
+
if (subdir.length > 0 && !validSubdir(subdir)) {
|
|
203
|
+
return { ok: false, error: `Unsupported skill source: invalid subdirectory "${subdir}"` };
|
|
204
|
+
}
|
|
205
|
+
const checked = checkUrlAllowed(`https://github.com/${owner}/${repo}`, allowedHosts);
|
|
206
|
+
if (!checked.ok)
|
|
207
|
+
return { ok: false, error: `Unsupported skill source: ${checked.error}` };
|
|
208
|
+
return {
|
|
209
|
+
ok: true,
|
|
210
|
+
spec: {
|
|
211
|
+
kind: 'git',
|
|
212
|
+
url: `https://github.com/${owner}/${repo}.git`,
|
|
213
|
+
ref,
|
|
214
|
+
subdir: subdir.length > 0 ? subdir : null,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function splitOnce(value, sep) {
|
|
219
|
+
const idx = value.indexOf(sep);
|
|
220
|
+
if (idx === -1)
|
|
221
|
+
return [value, undefined];
|
|
222
|
+
return [value.slice(0, idx), value.slice(idx + 1)];
|
|
223
|
+
}
|
|
224
|
+
async function defaultRunProcess(file, args, options) {
|
|
225
|
+
const { stdout } = await execFileAsync(file, args, {
|
|
226
|
+
// Rule 1: argv array, never a shell.
|
|
227
|
+
shell: false,
|
|
228
|
+
windowsHide: true,
|
|
229
|
+
timeout: GIT_TIMEOUT_MS,
|
|
230
|
+
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
231
|
+
// Rule 4: a credential prompt in a non-interactive install is a hang, not a
|
|
232
|
+
// question — the user would just see the CLI freeze with no explanation.
|
|
233
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', GCM_INTERACTIVE: 'never' },
|
|
234
|
+
});
|
|
235
|
+
return { stdout };
|
|
236
|
+
}
|
|
237
|
+
/** Fetch a resolved source into `stagingDir`, returning where the content landed. */
|
|
238
|
+
export async function fetchSource(spec, stagingDir, deps) {
|
|
239
|
+
await mkdir(stagingDir, { recursive: true });
|
|
240
|
+
switch (spec.kind) {
|
|
241
|
+
case 'local-dir':
|
|
242
|
+
return {
|
|
243
|
+
dir: spec.path,
|
|
244
|
+
source: { kind: 'local-dir', url: spec.path, ref: null, subdir: null },
|
|
245
|
+
};
|
|
246
|
+
// Single-file sources land in a dedicated subdirectory, never in
|
|
247
|
+
// `stagingDir` itself: the installer copies whatever directory we return
|
|
248
|
+
// into `<stagingDir>/content`, and returning the staging root would make
|
|
249
|
+
// that a copy of a directory into its own child.
|
|
250
|
+
case 'local-file': {
|
|
251
|
+
const dir = join(stagingDir, 'single');
|
|
252
|
+
await mkdir(dir, { recursive: true });
|
|
253
|
+
await writeFile(join(dir, 'SKILL.md'), await readFile(spec.path, 'utf-8'), 'utf-8');
|
|
254
|
+
return { dir, source: { kind: 'local-file', url: spec.path, ref: null, subdir: null } };
|
|
255
|
+
}
|
|
256
|
+
case 'https-md': {
|
|
257
|
+
const dir = join(stagingDir, 'single');
|
|
258
|
+
await mkdir(dir, { recursive: true });
|
|
259
|
+
const body = await downloadText(spec.url, SKILL_MD_DOWNLOAD_MAX_BYTES, deps);
|
|
260
|
+
await writeFile(join(dir, 'SKILL.md'), body, 'utf-8');
|
|
261
|
+
return { dir, source: { kind: 'https-md', url: spec.url, ref: null, subdir: null } };
|
|
262
|
+
}
|
|
263
|
+
case 'https-zip': {
|
|
264
|
+
const buffer = await downloadBuffer(spec.url, ARCHIVE_MAX_DOWNLOAD_BYTES, deps);
|
|
265
|
+
const extracted = join(stagingDir, 'unpacked');
|
|
266
|
+
await extractZip(buffer, extracted);
|
|
267
|
+
return {
|
|
268
|
+
dir: await descendToSkillRoot(extracted),
|
|
269
|
+
source: { kind: 'https-zip', url: spec.url, ref: null, subdir: null },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
case 'git': {
|
|
273
|
+
const cloneDir = join(stagingDir, 'clone');
|
|
274
|
+
await gitClone(spec, cloneDir, deps);
|
|
275
|
+
const root = spec.subdir ? join(cloneDir, spec.subdir) : cloneDir;
|
|
276
|
+
if (!isDirectory(root)) {
|
|
277
|
+
throw new Error(`subdirectory "${spec.subdir}" not found in the repository`);
|
|
278
|
+
}
|
|
279
|
+
// Read the commit BEFORE dropping .git — after the rm there is nothing
|
|
280
|
+
// left to ask. A branch name alone cannot answer "is the remote ahead?",
|
|
281
|
+
// which is what `update --check` needs (F9).
|
|
282
|
+
const resolvedRef = await readHeadSha(cloneDir, deps);
|
|
283
|
+
// Drop the VCS metadata: it is not part of the skill and would bloat the
|
|
284
|
+
// manifest and the size budget by an order of magnitude.
|
|
285
|
+
await rm(join(cloneDir, '.git'), { recursive: true, force: true });
|
|
286
|
+
return {
|
|
287
|
+
dir: await descendToSkillRoot(root),
|
|
288
|
+
source: { kind: 'git', url: spec.url, ref: spec.ref, subdir: spec.subdir, resolvedRef },
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Clone at a single revision.
|
|
295
|
+
*
|
|
296
|
+
* `--branch` accepts branches and tags but NOT a bare commit SHA (P2-6), so a
|
|
297
|
+
* sha-shaped ref takes the init+fetch+checkout path instead of failing with a
|
|
298
|
+
* confusing "Remote branch not found".
|
|
299
|
+
*/
|
|
300
|
+
async function gitClone(spec, cloneDir, deps) {
|
|
301
|
+
const run = deps.runProcess ?? defaultRunProcess;
|
|
302
|
+
const ref = spec.ref;
|
|
303
|
+
if (ref && COMMIT_SHA.test(ref)) {
|
|
304
|
+
await mkdir(cloneDir, { recursive: true });
|
|
305
|
+
await run('git', ['init', '--quiet'], { cwd: cloneDir });
|
|
306
|
+
await run('git', ['remote', 'add', 'origin', '--', spec.url], { cwd: cloneDir });
|
|
307
|
+
await run('git', ['fetch', '--depth', '1', 'origin', '--', ref], { cwd: cloneDir });
|
|
308
|
+
await run('git', ['checkout', '--quiet', 'FETCH_HEAD'], { cwd: cloneDir });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
// `--` separates options from operands so a hostile URL can never be read as
|
|
312
|
+
// a flag, even if every check above were somehow bypassed.
|
|
313
|
+
const args = ['clone', '--depth', '1', '--quiet'];
|
|
314
|
+
if (ref)
|
|
315
|
+
args.push('--branch', ref);
|
|
316
|
+
args.push('--', spec.url, cloneDir);
|
|
317
|
+
await run('git', args, {});
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Ask a remote for the sha behind `ref`, without downloading anything.
|
|
321
|
+
*
|
|
322
|
+
* Subject to the SAME four rules as every other subprocess here (D20): argv
|
|
323
|
+
* array, `shell:false`, no leading `-` on any model-influenced fragment, and no
|
|
324
|
+
* credential prompt. The caller must have re-validated the URL against the host
|
|
325
|
+
* allowlist first — this function does not do policy.
|
|
326
|
+
*
|
|
327
|
+
* Returns `null` for "cannot tell", which is the honest answer for a source
|
|
328
|
+
* git cannot reach, an unmatched ref, or a non-sha reply.
|
|
329
|
+
*/
|
|
330
|
+
export async function gitLsRemote(url, ref, deps) {
|
|
331
|
+
if (ref !== null && !validRef(ref))
|
|
332
|
+
return null;
|
|
333
|
+
const run = deps.runProcess ?? defaultRunProcess;
|
|
334
|
+
const args = ['ls-remote', '--exit-code', '--', url];
|
|
335
|
+
if (ref)
|
|
336
|
+
args.push(ref);
|
|
337
|
+
try {
|
|
338
|
+
const { stdout } = await run('git', args, {});
|
|
339
|
+
const sha = (stdout ?? '').trim().split(/\s+/)[0] ?? '';
|
|
340
|
+
return COMMIT_SHA.test(sha) ? sha : null;
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* The commit the clone actually landed on, or `null` if git would not say.
|
|
348
|
+
*
|
|
349
|
+
* Provenance is an OPTIMISATION here, never a precondition: a shallow clone
|
|
350
|
+
* that succeeded is a valid install even if `rev-parse` fails, so this swallows
|
|
351
|
+
* its errors instead of failing the install over a nicety.
|
|
352
|
+
*/
|
|
353
|
+
async function readHeadSha(cloneDir, deps) {
|
|
354
|
+
const run = deps.runProcess ?? defaultRunProcess;
|
|
355
|
+
try {
|
|
356
|
+
const { stdout } = await run('git', ['rev-parse', 'HEAD'], { cwd: cloneDir });
|
|
357
|
+
const sha = (stdout ?? '').trim();
|
|
358
|
+
return COMMIT_SHA.test(sha) ? sha : null;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* A repo whose root has no SKILL.md but exactly one subdirectory that does is
|
|
366
|
+
* the common `repo/skill-name/SKILL.md` layout — descend into it. Ambiguity
|
|
367
|
+
* (several candidates) is reported so the user can name the subdirectory.
|
|
368
|
+
*/
|
|
369
|
+
async function descendToSkillRoot(dir) {
|
|
370
|
+
const { readdirSync, existsSync } = await import('node:fs');
|
|
371
|
+
if (existsSync(join(dir, 'SKILL.md')))
|
|
372
|
+
return dir;
|
|
373
|
+
const candidates = readdirSync(dir, { withFileTypes: true })
|
|
374
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
375
|
+
.filter((e) => existsSync(join(dir, e.name, 'SKILL.md')))
|
|
376
|
+
.map((e) => e.name);
|
|
377
|
+
if (candidates.length === 1)
|
|
378
|
+
return join(dir, candidates[0]);
|
|
379
|
+
if (candidates.length > 1) {
|
|
380
|
+
throw new Error(`the source contains ${candidates.length} skills (${candidates.join(', ')}); ` +
|
|
381
|
+
'point at one of them explicitly');
|
|
382
|
+
}
|
|
383
|
+
return dir;
|
|
384
|
+
}
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// HTTP with a byte ceiling and redirect re-checking
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
async function httpGet(url, deps) {
|
|
389
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
390
|
+
let current = url;
|
|
391
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
392
|
+
// Re-check EVERY hop: an allowlisted host that 302s to an internal address
|
|
393
|
+
// would otherwise be a free SSRF.
|
|
394
|
+
const allowed = checkUrlAllowed(current, deps.allowedHosts);
|
|
395
|
+
if (!allowed.ok)
|
|
396
|
+
throw new Error(allowed.error);
|
|
397
|
+
const controller = new AbortController();
|
|
398
|
+
const timer = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
|
|
399
|
+
let response;
|
|
400
|
+
try {
|
|
401
|
+
response = await doFetch(current, {
|
|
402
|
+
redirect: 'manual',
|
|
403
|
+
signal: controller.signal,
|
|
404
|
+
headers: { 'User-Agent': deps.userAgent ?? 'aragon-agent-cli' },
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
finally {
|
|
408
|
+
clearTimeout(timer);
|
|
409
|
+
}
|
|
410
|
+
if (response.status >= 300 && response.status < 400) {
|
|
411
|
+
const location = response.headers.get('location');
|
|
412
|
+
if (!location)
|
|
413
|
+
throw new Error(`redirect without a Location header (${response.status})`);
|
|
414
|
+
current = new URL(location, current).toString();
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (!response.ok)
|
|
418
|
+
throw new Error(`HTTP ${response.status} fetching ${current}`);
|
|
419
|
+
return response;
|
|
420
|
+
}
|
|
421
|
+
throw new Error(`too many redirects (>${MAX_REDIRECTS})`);
|
|
422
|
+
}
|
|
423
|
+
/** Read a response body, aborting as soon as it passes `maxBytes`. */
|
|
424
|
+
async function readCapped(response, maxBytes) {
|
|
425
|
+
const declared = Number(response.headers.get('content-length') ?? '0');
|
|
426
|
+
if (declared > maxBytes) {
|
|
427
|
+
throw new Error(`response is ${declared} bytes (max ${maxBytes})`);
|
|
428
|
+
}
|
|
429
|
+
const chunks = [];
|
|
430
|
+
let total = 0;
|
|
431
|
+
const body = response.body;
|
|
432
|
+
if (!body)
|
|
433
|
+
return Buffer.alloc(0);
|
|
434
|
+
// Content-Length is advisory; the running total is the real gate.
|
|
435
|
+
for await (const chunk of body) {
|
|
436
|
+
total += chunk.byteLength;
|
|
437
|
+
if (total > maxBytes)
|
|
438
|
+
throw new Error(`response exceeded ${maxBytes} bytes`);
|
|
439
|
+
chunks.push(Buffer.from(chunk));
|
|
440
|
+
}
|
|
441
|
+
return Buffer.concat(chunks);
|
|
442
|
+
}
|
|
443
|
+
async function downloadText(url, maxBytes, deps) {
|
|
444
|
+
return (await readCapped(await httpGet(url, deps), maxBytes)).toString('utf-8');
|
|
445
|
+
}
|
|
446
|
+
async function downloadBuffer(url, maxBytes, deps) {
|
|
447
|
+
return readCapped(await httpGet(url, deps), maxBytes);
|
|
448
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install pipeline (spec §8.3): resolve → stage → validate → approve → commit.
|
|
3
|
+
*
|
|
4
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* THE APPROVAL STEP IS FAIL-CLOSED. "No human channel" means REFUSE.
|
|
6
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
7
|
+
*
|
|
8
|
+
* `ApprovalGate.canPrompt()` is probed BEFORE `request()` is called, and a
|
|
9
|
+
* `false` answer aborts the install (§8.3.1 / D17). This is not decoration:
|
|
10
|
+
* `cli.tsx` hands mutating tools a `confirm` callback that resolves `true` when
|
|
11
|
+
* no handler is attached. That is a fine default for `--confirm`, which is
|
|
12
|
+
* opt-in and only reachable with the TUI mounted. For skills it would be a
|
|
13
|
+
* silent hole — `requireApproval` defaults to `true`, and under `-p` the App
|
|
14
|
+
* never mounts, so the handler is permanently null. Reusing that callback would
|
|
15
|
+
* make `aragon -p "install the skill at <url> and use it"` write a third-party
|
|
16
|
+
* directory into the user's skills root with nobody asked and nothing logged.
|
|
17
|
+
*
|
|
18
|
+
* Probing first is what makes the safe behaviour the DEFAULT rather than a rule
|
|
19
|
+
* someone has to remember.
|
|
20
|
+
*/
|
|
21
|
+
import { type SkillScope, type StagedFile } from '@aragon-agent/core/skills';
|
|
22
|
+
import { MANIFEST_FILENAME } from './manifest.js';
|
|
23
|
+
import type { SkillService } from './service.js';
|
|
24
|
+
export interface InstallResult {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
name?: string;
|
|
27
|
+
version?: string;
|
|
28
|
+
scope?: SkillScope;
|
|
29
|
+
dir?: string;
|
|
30
|
+
fileCount?: number;
|
|
31
|
+
totalBytes?: number;
|
|
32
|
+
/** D14 — the Level 1 entry, inlined so the model can see it THIS turn. */
|
|
33
|
+
catalogLine?: string;
|
|
34
|
+
error?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface RemoveResult {
|
|
37
|
+
ok: boolean;
|
|
38
|
+
/** 'not_found' | 'immutable_scope' | 'cancelled' | 'io_error' */
|
|
39
|
+
reason?: string;
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface SkillCreateInput {
|
|
43
|
+
name: string;
|
|
44
|
+
description: string;
|
|
45
|
+
body: string;
|
|
46
|
+
files?: Array<{
|
|
47
|
+
path: string;
|
|
48
|
+
content: string;
|
|
49
|
+
}>;
|
|
50
|
+
scope?: SkillScope;
|
|
51
|
+
activation?: 'auto' | 'manual';
|
|
52
|
+
}
|
|
53
|
+
export type Initiator = 'user' | 'agent';
|
|
54
|
+
/** Where a scope's writable root lives; `null` when the scope cannot be written. */
|
|
55
|
+
export declare function writableRootFor(scope: SkillScope, cwd: string): string | null;
|
|
56
|
+
export declare function approve(service: SkillService, summary: string, tool?: string): Promise<{
|
|
57
|
+
ok: true;
|
|
58
|
+
} | {
|
|
59
|
+
ok: false;
|
|
60
|
+
error: string;
|
|
61
|
+
}>;
|
|
62
|
+
export declare function listStagedFiles(root: string, dir?: string, depth?: number): StagedFile[];
|
|
63
|
+
export declare function absoluteFiles(root: string, files: StagedFile[]): string[];
|
|
64
|
+
/**
|
|
65
|
+
* Replace `target` with `staged` as close to atomically as Windows allows.
|
|
66
|
+
*
|
|
67
|
+
* The displaced directory goes to `.staging/.trash/`, NOT to a sibling of the
|
|
68
|
+
* skills root. A `<root>/pdf-forms.bak-1785…` left behind by a crash mid-commit
|
|
69
|
+
* still contains a SKILL.md declaring `name: pdf-forms`, so the very next scan
|
|
70
|
+
* would find two skills with the same name in the same root and pick whichever
|
|
71
|
+
* `readdir` happened to return first. The user's experience of that is a skill
|
|
72
|
+
* that "sometimes" reverts to an old version. `.staging/.trash/` is dot-prefixed
|
|
73
|
+
* and therefore invisible to the scanner by construction.
|
|
74
|
+
*/
|
|
75
|
+
export declare function commitDirectory(staged: string, target: string): void;
|
|
76
|
+
/**
|
|
77
|
+
* Run the directory-replacement critical section under the per-root lock (§7).
|
|
78
|
+
*
|
|
79
|
+
* WHY THE LOCK IS TAKEN *HERE* AND NOT AT THE TOP OF THE INSTALL (deviation
|
|
80
|
+
* from spec §7.3, recorded in spec §20):
|
|
81
|
+
*
|
|
82
|
+
* The corruption §7.1 describes lives entirely inside `commitDirectory()` plus
|
|
83
|
+
* the manifest write — staging directories are per-UUID and cannot collide.
|
|
84
|
+
* Taking the lock earlier would mean holding it across a network fetch AND
|
|
85
|
+
* across the human approval prompt. `LOCK_TTL_MS` is 60 s, and a user who takes
|
|
86
|
+
* longer than that to answer would have their lock judged stale and preempted —
|
|
87
|
+
* so the protection would silently switch itself off in exactly the interactive
|
|
88
|
+
* case it was written for, while a concurrent install would meanwhile fail with
|
|
89
|
+
* a lock error during a perfectly legitimate parallel download.
|
|
90
|
+
*
|
|
91
|
+
* Spec §6.2 already applies this reasoning to `update` (approval at step 7,
|
|
92
|
+
* lock at step 8). This is the same rule, applied to the one pipeline whose
|
|
93
|
+
* approval necessarily comes after the fetch.
|
|
94
|
+
*/
|
|
95
|
+
export declare function withRootLock<T>(root: string, critical: () => T): T;
|
|
96
|
+
export declare function installSkill(service: SkillService, source: string, opts: {
|
|
97
|
+
scope?: SkillScope;
|
|
98
|
+
name?: string;
|
|
99
|
+
initiator: Initiator;
|
|
100
|
+
cwd: string;
|
|
101
|
+
installer: string;
|
|
102
|
+
}): Promise<InstallResult>;
|
|
103
|
+
export declare function createSkill(service: SkillService, input: SkillCreateInput & {
|
|
104
|
+
initiator: Initiator;
|
|
105
|
+
}, opts: {
|
|
106
|
+
cwd: string;
|
|
107
|
+
installer: string;
|
|
108
|
+
}): Promise<InstallResult>;
|
|
109
|
+
/**
|
|
110
|
+
* Refusal is a NORMAL outcome here, not an exception — bundled, env and
|
|
111
|
+
* `.claude/skills` skills are all legitimately un-removable — which is why this
|
|
112
|
+
* returns a result object rather than `Promise<void>`.
|
|
113
|
+
*/
|
|
114
|
+
export declare function removeSkill(service: SkillService, name: string): Promise<RemoveResult>;
|
|
115
|
+
/** Assemble a SKILL.md from `skill_create` parameters. */
|
|
116
|
+
export declare function renderSkillMarkdown(input: SkillCreateInput): string;
|
|
117
|
+
export { MANIFEST_FILENAME };
|