termdock 1.4.71 → 1.4.73
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/README.md +59 -0
- package/dist/client/assets/{ModelPreview-CG6vLwyw.js → ModelPreview-BYS46cJp.js} +1 -1
- package/dist/client/assets/{abnfDiagram-VRR7QNED-CrXEZLWX.js → abnfDiagram-VRR7QNED-Df5iizBM.js} +1 -1
- package/dist/client/assets/{arc-DbKGR7Ex.js → arc-BRLIrHaj.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-PF5BoUbI.js → architectureDiagram-ZJ3FMSHR-BmIKhrrq.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-D1gRzVWB.js → blockDiagram-677ZJIJ3-PBtq2qLG.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-CwvOLync.js → c4Diagram-LMCZKHZV-6pevynP4.js} +1 -1
- package/dist/client/assets/channel-CfecnPVa.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-BeRdP-xP.js → chunk-2Q5K7J3B-78EZViva.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-CrqR5rvl.js → chunk-32BRIVSS-CekczK5l.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-CjbUr-ko.js → chunk-5VM5RSS4-fMhlGbVA.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-BVEk3Wma.js → chunk-EX3LRPZG-DOBewV4Z.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-WitJe8kR.js → chunk-JWPE2WC7-rtk-mGC7.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-BCewlLYk.js → chunk-MOJQB5TN-CWUZocq_.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-CoP0AA4G.js → chunk-RYQCIY6F-DcHAjeyV.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-CSZrnmp_.js → chunk-V7JOEXUC-C2paDSKz.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-BB-7zz-9.js → chunk-VR4S4FIN-Cq0ZrFUq.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-CcZZlCik.js → chunk-XXDRQBXY-BboySYmP.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-CFpMarVm.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-CFpMarVm.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-DcmzUDwh.js → cose-bilkent-JH36ORCC-CMnHIr6y.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-C1XlDVoJ.js → cynefin-VYW2F7L2-BpOtLuYh.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-zY6Aet2S.js → cynefinDiagram-TSTJHNR4-CmrMqA7z.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-DqGTi6zR.js → dagre-VKFMJZFB-iXOV-rm7.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-DStcsU7S.js → diagram-FQU43EPY-D5KagnmX.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-Cs6IMNip.js → diagram-G47NLZAW-CglgkfyY.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-yA2bpWN7.js → diagram-NH7WQ7WH-Dos-y1pl.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-Dzf9M0PI.js → diagram-OA4YK3LP-Cw6yJqFX.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-D75lpQKA.js → diagram-WEI45ONY-C_MHHYRA.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-CkQvIpWy.js → ebnfDiagram-CCIWWBDH-BB7x8X2l.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-Ca00Vjxz.js → erDiagram-Q63AITRT-BznrrU3P.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-CGrnSTEp.js → flowDiagram-23GEKE2U-CMzPvwGa.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-0SHfOIP4.js → ganttDiagram-NO4QXBWP-Dc9JCHv3.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-BeigYWvs.js → gitGraphDiagram-IHSO6WYX-WlPpmZQm.js} +1 -1
- package/dist/client/assets/index-wX3Y6Ks7.js +451 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-CiY1-xjN.js → infoDiagram-FWYZ7A6U-DC6PBJiN.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-CmtDjUm_.js → ishikawaDiagram-FXEZZL3T-DPfxPu0W.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-B7qOr9LY.js → journeyDiagram-5HDEW3XC-aA2ovr_f.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-DO2P2Um3.js → kanban-definition-HUTT4EX6-_T30f7Or.js} +1 -1
- package/dist/client/assets/{linear-B832hJt0.js → linear-DRZcO7_m.js} +1 -1
- package/dist/client/assets/{mermaid.core-CsAzdiCU.js → mermaid.core-BGXjYfdb.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-HI__vXhQ.js → mindmap-definition-LN4V7U3C-Bv0lhpqs.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR--rZIwjXq.js → pegDiagram-2B236MQR-D-EIIOZf.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-D9jPf5x9.js → pieDiagram-ENE6RG2P-CATAcPgl.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-DTCP5ji2.js → quadrantDiagram-ABIIQ3AL-2tglHVSk.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-D2Gspvrl.js → railroadDiagram-RFXS5EU6-BMKC-e8R.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-DEJC02IW.js → requirementDiagram-TGXJPOKE-B5J5_wFW.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-opOhKPap.js → sankeyDiagram-HTMAVEWB-Djlm71ae.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-_sz2CRAG.js → sequenceDiagram-DBY2YBRQ-BtR8OPSJ.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-D0Q2QkYF.js → sizeCapture-X5ZJPWSS-CDelD9h_.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-VgeSQTtW.js → stateDiagram-2N3HPSRC-C50kGru1.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-gIr1L4WZ.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-ClvBvSl0.js → swimlanes-5IMT3BWC-COHxl86-.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-COE4p0kX.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-BXNsrECV.js → timeline-definition-FHXFAJF6-br-VI8Fd.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-BgwiqZd-.js → vennDiagram-L72KCM5P-zwMtEv81.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-DiqN5ERx.js → wardleyDiagram-EHGQE667-_C8v4nnr.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-DQjFVqC6.js → xychartDiagram-FW5EYKEG-CSU2R6pt.js} +1 -1
- package/dist/client/index.html +1 -1
- package/dist/client/sw.js +1 -1
- package/dist/server/agent/autoTitle.js +3 -0
- package/dist/server/agent/pluginPackages.js +289 -0
- package/dist/server/agent/plugins.js +139 -20
- package/dist/server/agent/titleNamerCatalog.js +235 -40
- package/dist/server/cli.js +277 -17
- package/dist/server/entry.js +11 -2
- package/dist/server/routes/terminal.js +241 -30
- package/dist/server/utils/csrfProtection.js +4 -1
- package/dist/server/utils/settings.js +14 -0
- package/package.json +1 -1
- package/runtime-manifest.json +2 -2
- package/dist/client/assets/channel-JTEuKVy6.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-BiFJB_5s.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-BiFJB_5s.js +0 -1
- package/dist/client/assets/index-D5PKdbhW.js +0 -451
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BVh2FRO-.js +0 -1
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-C17V-kgm.js +0 -8
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { ICON_FILE, MANIFEST_FILE, PLUGINS_DIR, SOURCE_METADATA_FILE, validateManifest, writePluginSourceMetadata, } from './plugins.js';
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const MAX_PACKAGE_FILES = 512;
|
|
10
|
+
const MAX_PACKAGE_BYTES = 16 * 1024 * 1024;
|
|
11
|
+
const MAX_SINGLE_FILE_BYTES = 8 * 1024 * 1024;
|
|
12
|
+
const GIT_TIMEOUT_MS = 45_000;
|
|
13
|
+
const IGNORED_NAMES = new Set(['.git', '.github', 'node_modules', SOURCE_METADATA_FILE]);
|
|
14
|
+
const DEFAULT_GIT_HOSTS = new Set(['github.com', 'gitlab.com', 'codeberg.org', 'bitbucket.org']);
|
|
15
|
+
function hardenedGitEnv() {
|
|
16
|
+
return {
|
|
17
|
+
...process.env,
|
|
18
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
19
|
+
GIT_CONFIG_COUNT: '0',
|
|
20
|
+
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
21
|
+
GIT_CONFIG_SYSTEM: '/dev/null',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function allowedGitHosts() {
|
|
25
|
+
const configured = (process.env.TERMDOCK_PLUGIN_GIT_HOSTS ?? '')
|
|
26
|
+
.split(',')
|
|
27
|
+
.map((host) => host.trim().toLowerCase())
|
|
28
|
+
.filter(Boolean);
|
|
29
|
+
return new Set([...DEFAULT_GIT_HOSTS, ...configured]);
|
|
30
|
+
}
|
|
31
|
+
export function normalizeGitPluginSource(input) {
|
|
32
|
+
let url;
|
|
33
|
+
try {
|
|
34
|
+
url = new URL(input.trim());
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash)
|
|
40
|
+
return null;
|
|
41
|
+
if (!allowedGitHosts().has(url.hostname.toLowerCase()))
|
|
42
|
+
return null;
|
|
43
|
+
if (!/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?\/?$/.test(url.pathname))
|
|
44
|
+
return null;
|
|
45
|
+
url.pathname = url.pathname.replace(/\/$/, '');
|
|
46
|
+
return url.toString();
|
|
47
|
+
}
|
|
48
|
+
async function packageTreeDigest(root) {
|
|
49
|
+
const hash = createHash('sha256');
|
|
50
|
+
let files = 0;
|
|
51
|
+
let bytes = 0;
|
|
52
|
+
async function visit(directory, relative = '') {
|
|
53
|
+
const entries = await fs.promises.readdir(directory, { withFileTypes: true });
|
|
54
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (IGNORED_NAMES.has(entry.name))
|
|
57
|
+
continue;
|
|
58
|
+
if (entry.isSymbolicLink())
|
|
59
|
+
throw new Error(`Plugin packages may not contain symbolic links: ${path.join(relative, entry.name)}`);
|
|
60
|
+
const absolute = path.join(directory, entry.name);
|
|
61
|
+
const nextRelative = path.join(relative, entry.name);
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
await visit(absolute, nextRelative);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!entry.isFile())
|
|
67
|
+
throw new Error(`Unsupported plugin package entry: ${nextRelative}`);
|
|
68
|
+
const stat = await fs.promises.stat(absolute);
|
|
69
|
+
files += 1;
|
|
70
|
+
bytes += stat.size;
|
|
71
|
+
if (files > MAX_PACKAGE_FILES)
|
|
72
|
+
throw new Error(`Plugin package exceeds ${MAX_PACKAGE_FILES} files`);
|
|
73
|
+
if (stat.size > MAX_SINGLE_FILE_BYTES)
|
|
74
|
+
throw new Error(`Plugin file is too large: ${nextRelative}`);
|
|
75
|
+
if (bytes > MAX_PACKAGE_BYTES)
|
|
76
|
+
throw new Error('Plugin package exceeds the 16 MB unpacked limit');
|
|
77
|
+
hash.update(nextRelative.replaceAll(path.sep, '/'));
|
|
78
|
+
hash.update('\0');
|
|
79
|
+
hash.update(await fs.promises.readFile(absolute));
|
|
80
|
+
hash.update('\0');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
await visit(root);
|
|
84
|
+
return hash.digest('hex');
|
|
85
|
+
}
|
|
86
|
+
async function copyPackageTree(source, destination) {
|
|
87
|
+
await packageTreeDigest(source); // validates limits and symlinks before any destination write
|
|
88
|
+
await fs.promises.mkdir(destination, { recursive: true });
|
|
89
|
+
async function copy(directory, output) {
|
|
90
|
+
const entries = await fs.promises.readdir(directory, { withFileTypes: true });
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (IGNORED_NAMES.has(entry.name))
|
|
93
|
+
continue;
|
|
94
|
+
const from = path.join(directory, entry.name);
|
|
95
|
+
const to = path.join(output, entry.name);
|
|
96
|
+
if (entry.isDirectory()) {
|
|
97
|
+
await fs.promises.mkdir(to, { recursive: true });
|
|
98
|
+
await copy(from, to);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
await fs.promises.copyFile(from, to);
|
|
102
|
+
const stat = await fs.promises.stat(from);
|
|
103
|
+
await fs.promises.chmod(to, stat.mode & 0o777);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
await copy(source, destination);
|
|
108
|
+
}
|
|
109
|
+
function validateIcon(packageDir) {
|
|
110
|
+
const iconPath = path.join(packageDir, ICON_FILE);
|
|
111
|
+
if (!fs.existsSync(iconPath))
|
|
112
|
+
return;
|
|
113
|
+
const svg = fs.readFileSync(iconPath, 'utf8');
|
|
114
|
+
if (!/^\s*<svg\b/i.test(svg))
|
|
115
|
+
throw new Error('icon.svg must contain an SVG document');
|
|
116
|
+
if (/<(?:script|style|foreignObject|iframe|object|embed)\b/i.test(svg)
|
|
117
|
+
|| /<\?(?:xml|xml-stylesheet)\b|<!DOCTYPE\b/i.test(svg)
|
|
118
|
+
|| /\bon[a-z]+\s*=/i.test(svg)
|
|
119
|
+
|| /(?:href|src)\s*=\s*["']?\s*(?!#)[^"'\s>]+/i.test(svg)
|
|
120
|
+
|| /url\(\s*(?!["']?#)[^)]+\)/i.test(svg)) {
|
|
121
|
+
throw new Error('icon.svg contains unsafe active content');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function readAndValidateManifest(packageDir, expectedSlug) {
|
|
125
|
+
const manifestPath = path.join(packageDir, MANIFEST_FILE);
|
|
126
|
+
let raw;
|
|
127
|
+
try {
|
|
128
|
+
raw = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
throw new Error(`Plugin package must contain a valid root manifest.json: ${error.message}`);
|
|
132
|
+
}
|
|
133
|
+
const validation = validateManifest(raw, packageDir);
|
|
134
|
+
if ('error' in validation) {
|
|
135
|
+
const error = new Error(validation.error.errors.join('\n'));
|
|
136
|
+
error.code = validation.error.code ?? 'AGENT_PLUGIN_MANIFEST_INVALID';
|
|
137
|
+
error.migration = validation.error.migration;
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
if (expectedSlug && validation.manifest.slug !== expectedSlug) {
|
|
141
|
+
throw new Error(`Update source declares slug "${validation.manifest.slug}"; expected "${expectedSlug}"`);
|
|
142
|
+
}
|
|
143
|
+
validateIcon(packageDir);
|
|
144
|
+
return validation.manifest;
|
|
145
|
+
}
|
|
146
|
+
export async function preparePluginPackage(sourceInput, expectedSlug) {
|
|
147
|
+
const source = sourceInput.trim();
|
|
148
|
+
if (!source)
|
|
149
|
+
throw new Error('Plugin source is required');
|
|
150
|
+
const remote = normalizeGitPluginSource(source);
|
|
151
|
+
const temporaryRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'termdock-plugin-'));
|
|
152
|
+
let packageDir;
|
|
153
|
+
let type;
|
|
154
|
+
let normalizedSource;
|
|
155
|
+
let revision;
|
|
156
|
+
try {
|
|
157
|
+
if (remote) {
|
|
158
|
+
packageDir = path.join(temporaryRoot, 'repo');
|
|
159
|
+
await execFileAsync('git', [
|
|
160
|
+
'-c', 'credential.helper=',
|
|
161
|
+
'-c', 'core.hooksPath=/dev/null',
|
|
162
|
+
'-c', 'filter.lfs.smudge=',
|
|
163
|
+
'-c', 'filter.lfs.required=false',
|
|
164
|
+
'clone', '--depth', '1', '--single-branch', '--no-recurse-submodules', '--', remote, packageDir,
|
|
165
|
+
], {
|
|
166
|
+
timeout: GIT_TIMEOUT_MS,
|
|
167
|
+
maxBuffer: 1024 * 1024,
|
|
168
|
+
env: hardenedGitEnv(),
|
|
169
|
+
});
|
|
170
|
+
const result = await execFileAsync('git', ['-C', packageDir, 'rev-parse', 'HEAD'], {
|
|
171
|
+
timeout: 5000,
|
|
172
|
+
maxBuffer: 128 * 1024,
|
|
173
|
+
});
|
|
174
|
+
revision = result.stdout.trim();
|
|
175
|
+
type = 'git';
|
|
176
|
+
normalizedSource = remote;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
const resolved = path.resolve(source);
|
|
180
|
+
const stat = await fs.promises.stat(resolved).catch(() => null);
|
|
181
|
+
if (!stat)
|
|
182
|
+
throw new Error('Plugin source must be an existing local path or an allowed HTTPS Git repository');
|
|
183
|
+
packageDir = stat.isDirectory() ? resolved : path.dirname(resolved);
|
|
184
|
+
if (!stat.isDirectory() && path.basename(resolved) !== MANIFEST_FILE) {
|
|
185
|
+
throw new Error('A local plugin file must be named manifest.json');
|
|
186
|
+
}
|
|
187
|
+
type = 'local';
|
|
188
|
+
normalizedSource = packageDir;
|
|
189
|
+
revision = await packageTreeDigest(packageDir);
|
|
190
|
+
}
|
|
191
|
+
const manifest = await readAndValidateManifest(packageDir, expectedSlug);
|
|
192
|
+
const previous = expectedSlug
|
|
193
|
+
? (() => {
|
|
194
|
+
try {
|
|
195
|
+
return JSON.parse(fs.readFileSync(path.join(PLUGINS_DIR, expectedSlug, SOURCE_METADATA_FILE), 'utf8'));
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
})()
|
|
201
|
+
: null;
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
return {
|
|
204
|
+
manifest,
|
|
205
|
+
packageDir,
|
|
206
|
+
metadata: {
|
|
207
|
+
version: 1,
|
|
208
|
+
type,
|
|
209
|
+
source: normalizedSource,
|
|
210
|
+
revision,
|
|
211
|
+
latestRevision: revision,
|
|
212
|
+
installedAt: typeof previous?.installedAt === 'number' ? previous.installedAt : now,
|
|
213
|
+
updatedAt: now,
|
|
214
|
+
checkedAt: now,
|
|
215
|
+
},
|
|
216
|
+
cleanup: () => fs.promises.rm(temporaryRoot, { recursive: true, force: true }),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
await fs.promises.rm(temporaryRoot, { recursive: true, force: true });
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
export async function commitPreparedPlugin(prepared, replace = false) {
|
|
225
|
+
await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
|
|
226
|
+
const target = path.join(PLUGINS_DIR, prepared.manifest.slug);
|
|
227
|
+
if (!replace && fs.existsSync(target))
|
|
228
|
+
throw new Error(`Plugin "${prepared.manifest.slug}" already exists`);
|
|
229
|
+
const stage = path.join(PLUGINS_DIR, `.stage-${prepared.manifest.slug}-${randomUUID()}`);
|
|
230
|
+
const backup = path.join(PLUGINS_DIR, `.backup-${prepared.manifest.slug}-${randomUUID()}`);
|
|
231
|
+
try {
|
|
232
|
+
await copyPackageTree(prepared.packageDir, stage);
|
|
233
|
+
await fs.promises.writeFile(path.join(stage, MANIFEST_FILE), JSON.stringify(prepared.manifest, null, 2), 'utf8');
|
|
234
|
+
writePluginSourceMetadata(stage, prepared.metadata);
|
|
235
|
+
if (fs.existsSync(target))
|
|
236
|
+
await fs.promises.rename(target, backup);
|
|
237
|
+
try {
|
|
238
|
+
await fs.promises.rename(stage, target);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (fs.existsSync(backup))
|
|
242
|
+
await fs.promises.rename(backup, target);
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
await fs.promises.rm(backup, { recursive: true, force: true });
|
|
246
|
+
return target;
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
await fs.promises.rm(stage, { recursive: true, force: true });
|
|
250
|
+
await prepared.cleanup();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
export async function checkPluginPackageUpdate(plugin) {
|
|
254
|
+
const checkedAt = Date.now();
|
|
255
|
+
const metadata = plugin.source;
|
|
256
|
+
if (!metadata?.source || metadata.type === 'manifest') {
|
|
257
|
+
return { supported: false, updateAvailable: false, revision: metadata?.revision ?? null, latestRevision: null, checkedAt };
|
|
258
|
+
}
|
|
259
|
+
let latestRevision;
|
|
260
|
+
if (metadata.type === 'git') {
|
|
261
|
+
const remote = normalizeGitPluginSource(metadata.source);
|
|
262
|
+
if (!remote)
|
|
263
|
+
throw new Error('The stored Git source is no longer allowed');
|
|
264
|
+
const { stdout } = await execFileAsync('git', [
|
|
265
|
+
'-c', 'credential.helper=',
|
|
266
|
+
'-c', 'core.hooksPath=/dev/null',
|
|
267
|
+
'ls-remote', remote, 'HEAD',
|
|
268
|
+
], {
|
|
269
|
+
timeout: 15_000,
|
|
270
|
+
maxBuffer: 128 * 1024,
|
|
271
|
+
env: hardenedGitEnv(),
|
|
272
|
+
});
|
|
273
|
+
latestRevision = stdout.trim().split(/\s+/)[0] ?? '';
|
|
274
|
+
if (!/^[0-9a-f]{40,64}$/i.test(latestRevision))
|
|
275
|
+
throw new Error('Unable to resolve the plugin repository HEAD');
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
latestRevision = await packageTreeDigest(metadata.source);
|
|
279
|
+
}
|
|
280
|
+
const next = { ...metadata, latestRevision, checkedAt };
|
|
281
|
+
writePluginSourceMetadata(plugin.dir, next);
|
|
282
|
+
return {
|
|
283
|
+
supported: true,
|
|
284
|
+
updateAvailable: Boolean(metadata.revision && latestRevision !== metadata.revision),
|
|
285
|
+
revision: metadata.revision,
|
|
286
|
+
latestRevision,
|
|
287
|
+
checkedAt,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
@@ -13,13 +13,42 @@
|
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import os from 'node:os';
|
|
16
|
-
const PLUGINS_DIR = path.join(os.homedir(), '.termdock', 'agent-plugins');
|
|
17
|
-
const MANIFEST_FILE = 'manifest.json';
|
|
18
|
-
const ICON_FILE = 'icon.svg';
|
|
16
|
+
export const PLUGINS_DIR = path.join(os.homedir(), '.termdock', 'agent-plugins');
|
|
17
|
+
export const MANIFEST_FILE = 'manifest.json';
|
|
18
|
+
export const ICON_FILE = 'icon.svg';
|
|
19
|
+
export const SOURCE_METADATA_FILE = '.termdock-source.json';
|
|
19
20
|
// ---------------------------------------------------------------------------
|
|
20
21
|
// Manifest schema
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
22
23
|
export const PLUGIN_MANIFEST_VERSION = 2;
|
|
24
|
+
function parseSourceMetadata(raw) {
|
|
25
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
26
|
+
return null;
|
|
27
|
+
const value = raw;
|
|
28
|
+
if (value.version !== 1 || !['git', 'local', 'manifest'].includes(String(value.type)))
|
|
29
|
+
return null;
|
|
30
|
+
return {
|
|
31
|
+
version: 1,
|
|
32
|
+
type: value.type,
|
|
33
|
+
source: typeof value.source === 'string' ? value.source : null,
|
|
34
|
+
revision: typeof value.revision === 'string' ? value.revision : null,
|
|
35
|
+
latestRevision: typeof value.latestRevision === 'string' ? value.latestRevision : null,
|
|
36
|
+
installedAt: typeof value.installedAt === 'number' ? value.installedAt : 0,
|
|
37
|
+
updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : 0,
|
|
38
|
+
checkedAt: typeof value.checkedAt === 'number' ? value.checkedAt : null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function readPluginSourceMetadata(dir) {
|
|
42
|
+
try {
|
|
43
|
+
return parseSourceMetadata(JSON.parse(fs.readFileSync(path.join(dir, SOURCE_METADATA_FILE), 'utf8')));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function writePluginSourceMetadata(dir, metadata) {
|
|
50
|
+
fs.writeFileSync(path.join(dir, SOURCE_METADATA_FILE), JSON.stringify(metadata, null, 2), 'utf8');
|
|
51
|
+
}
|
|
23
52
|
// ---------------------------------------------------------------------------
|
|
24
53
|
// Known sentinel event kinds — the set of events our state machine accepts
|
|
25
54
|
// ---------------------------------------------------------------------------
|
|
@@ -48,6 +77,14 @@ function isValidSlug(s) {
|
|
|
48
77
|
// Hex color validation
|
|
49
78
|
// ---------------------------------------------------------------------------
|
|
50
79
|
const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
|
|
80
|
+
function isSafeSvgIcon(svg) {
|
|
81
|
+
return /^\s*<svg\b/i.test(svg)
|
|
82
|
+
&& !/<(?:script|style|foreignObject|iframe|object|embed)\b/i.test(svg)
|
|
83
|
+
&& !/<\?(?:xml|xml-stylesheet)\b|<!DOCTYPE\b/i.test(svg)
|
|
84
|
+
&& !/\bon[a-z]+\s*=/i.test(svg)
|
|
85
|
+
&& !/(?:href|src)\s*=\s*["']?\s*(?!#)[^"'\s>]+/i.test(svg)
|
|
86
|
+
&& !/url\(\s*(?!["']?#)[^)]+\)/i.test(svg);
|
|
87
|
+
}
|
|
51
88
|
// ---------------------------------------------------------------------------
|
|
52
89
|
// Path resolution: ~ → home directory
|
|
53
90
|
// ---------------------------------------------------------------------------
|
|
@@ -57,6 +94,33 @@ function resolveHome(p) {
|
|
|
57
94
|
}
|
|
58
95
|
return p;
|
|
59
96
|
}
|
|
97
|
+
function validateHookTarget(target) {
|
|
98
|
+
if (!target.startsWith('~/') || !target.toLowerCase().endsWith('.json')) {
|
|
99
|
+
return 'hooks.target must be a ~/ path to a JSON file';
|
|
100
|
+
}
|
|
101
|
+
const home = path.resolve(os.homedir());
|
|
102
|
+
const resolved = path.resolve(home, target.slice(2));
|
|
103
|
+
if (resolved === home || !resolved.startsWith(`${home}${path.sep}`)) {
|
|
104
|
+
return 'hooks.target must stay inside the user home directory';
|
|
105
|
+
}
|
|
106
|
+
// Existing symlinks in any target component could redirect a later write
|
|
107
|
+
// outside $HOME. Refuse them instead of relying on a racy realpath check.
|
|
108
|
+
let current = home;
|
|
109
|
+
for (const part of path.relative(home, resolved).split(path.sep)) {
|
|
110
|
+
current = path.join(current, part);
|
|
111
|
+
try {
|
|
112
|
+
if (fs.lstatSync(current).isSymbolicLink()) {
|
|
113
|
+
return 'hooks.target may not traverse symbolic links';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (error.code !== 'ENOENT') {
|
|
118
|
+
return 'hooks.target could not be safely inspected';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
60
124
|
export const V1_MIGRATION_GUIDE_COMMAND = 'td agent-plugin --json';
|
|
61
125
|
export const V1_MIGRATION_AI_PROMPT = [
|
|
62
126
|
'Please migrate the attached Termdock Agent plugin manifest from v1 to v2.',
|
|
@@ -86,12 +150,13 @@ export function validateManifest(raw, dir) {
|
|
|
86
150
|
errors.push('slug must be a lowercase alphanumeric string with hyphens (max 40 chars)');
|
|
87
151
|
}
|
|
88
152
|
const displayName = m.displayName;
|
|
89
|
-
if (typeof displayName !== 'string' || displayName.trim().length === 0) {
|
|
90
|
-
errors.push('displayName is required and must be a non-empty string');
|
|
153
|
+
if (typeof displayName !== 'string' || displayName.trim().length === 0 || displayName.length > 80) {
|
|
154
|
+
errors.push('displayName is required and must be a non-empty string (max 80 chars)');
|
|
91
155
|
}
|
|
92
156
|
const aliases = m.aliases;
|
|
93
|
-
if (!Array.isArray(aliases) || aliases.length === 0 ||
|
|
94
|
-
|
|
157
|
+
if (!Array.isArray(aliases) || aliases.length === 0 || aliases.length > 16
|
|
158
|
+
|| !aliases.every((a) => typeof a === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(a))) {
|
|
159
|
+
errors.push('aliases must contain 1-16 safe command names');
|
|
95
160
|
}
|
|
96
161
|
const accentColor = m.accentColor;
|
|
97
162
|
if (typeof accentColor !== 'string' || !HEX_COLOR_RE.test(accentColor)) {
|
|
@@ -167,6 +232,11 @@ export function validateManifest(raw, dir) {
|
|
|
167
232
|
if (typeof target !== 'string' || target.trim().length === 0) {
|
|
168
233
|
errors.push('hooks.target is required and must be a file path (~-abbreviated ok)');
|
|
169
234
|
}
|
|
235
|
+
else {
|
|
236
|
+
const targetError = validateHookTarget(target.trim());
|
|
237
|
+
if (targetError)
|
|
238
|
+
errors.push(targetError);
|
|
239
|
+
}
|
|
170
240
|
const events = h.events;
|
|
171
241
|
if (!Array.isArray(events) || events.length === 0) {
|
|
172
242
|
errors.push('hooks.events must be a non-empty array');
|
|
@@ -182,15 +252,15 @@ export function validateManifest(raw, dir) {
|
|
|
182
252
|
const evt = e;
|
|
183
253
|
const hook = evt.hook;
|
|
184
254
|
const event = evt.event;
|
|
185
|
-
if (typeof hook !== 'string' || hook.trim().length === 0) {
|
|
255
|
+
if (typeof hook !== 'string' || hook.trim().length === 0 || hook.length > 120) {
|
|
186
256
|
errors.push(`hooks.events[${i}].hook is required`);
|
|
187
257
|
}
|
|
188
258
|
if (typeof event !== 'string' || !VALID_EVENT_KINDS.has(event)) {
|
|
189
259
|
errors.push(`hooks.events[${i}].event must be one of: ${[...VALID_EVENT_KINDS].join(', ')}`);
|
|
190
260
|
}
|
|
191
261
|
const matcher = evt.matcher;
|
|
192
|
-
if (matcher !== undefined && typeof matcher !== 'string') {
|
|
193
|
-
errors.push(`hooks.events[${i}].matcher must be a string if provided`);
|
|
262
|
+
if (matcher !== undefined && (typeof matcher !== 'string' || matcher.length > 512)) {
|
|
263
|
+
errors.push(`hooks.events[${i}].matcher must be a string (max 512 chars) if provided`);
|
|
194
264
|
}
|
|
195
265
|
const timeout = evt.timeout;
|
|
196
266
|
if (timeout !== undefined && (typeof timeout !== 'number' || timeout <= 0)) {
|
|
@@ -226,9 +296,20 @@ export function validateManifest(raw, dir) {
|
|
|
226
296
|
if (typeof command !== 'string' || !command.includes('{sessionId}')) {
|
|
227
297
|
errors.push('resume.command must contain the {sessionId} placeholder');
|
|
228
298
|
}
|
|
299
|
+
else {
|
|
300
|
+
const tokens = command.trim().split(/\s+/);
|
|
301
|
+
const executable = path.basename(tokens[0] ?? '');
|
|
302
|
+
if (command.length > 1024
|
|
303
|
+
|| !tokens.every((token) => /^[A-Za-z0-9_./:@%+,={}-]+$/.test(token))
|
|
304
|
+
|| !Array.isArray(aliases)
|
|
305
|
+
|| !aliases.includes(executable)) {
|
|
306
|
+
errors.push('resume.command must be a simple argv-like command whose executable is one of aliases; shell syntax is not allowed');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
229
309
|
const staleFlags = r.staleFlags;
|
|
230
|
-
if (staleFlags !== undefined && (!Array.isArray(staleFlags) ||
|
|
231
|
-
|
|
310
|
+
if (staleFlags !== undefined && (!Array.isArray(staleFlags) || staleFlags.length > 32
|
|
311
|
+
|| !staleFlags.every((f) => typeof f === 'string' && /^--?[A-Za-z0-9][A-Za-z0-9-]{0,79}$/.test(f)))) {
|
|
312
|
+
errors.push('resume.staleFlags must be an array of safe CLI flags if provided');
|
|
232
313
|
}
|
|
233
314
|
resumeConfig = {
|
|
234
315
|
command: command,
|
|
@@ -248,12 +329,24 @@ export function validateManifest(raw, dir) {
|
|
|
248
329
|
const n = m.titleNamer;
|
|
249
330
|
const command = n.command;
|
|
250
331
|
const args = n.args;
|
|
251
|
-
|
|
332
|
+
const modelArgs = n.modelArgs;
|
|
333
|
+
if (typeof command !== 'string' || command.trim().length === 0 || command.length > 1024) {
|
|
252
334
|
errors.push('titleNamer.command is required');
|
|
253
335
|
}
|
|
254
|
-
if (!Array.isArray(args) ||
|
|
336
|
+
if (!Array.isArray(args) || args.length > 64
|
|
337
|
+
|| !args.every((arg) => typeof arg === 'string' && arg.length <= 16_384)
|
|
338
|
+
|| !args.some((arg) => arg.includes('{prompt}'))) {
|
|
255
339
|
errors.push('titleNamer.args must be a string array containing {prompt}');
|
|
256
340
|
}
|
|
341
|
+
if (modelArgs !== undefined && (!Array.isArray(modelArgs) || modelArgs.length === 0 || modelArgs.length > 32
|
|
342
|
+
|| !modelArgs.every((arg) => typeof arg === 'string' && arg.length <= 4096)
|
|
343
|
+
|| !modelArgs.some((arg) => arg.includes('{model}')))) {
|
|
344
|
+
errors.push('titleNamer.modelArgs must be a non-empty string array containing {model} if provided');
|
|
345
|
+
}
|
|
346
|
+
if (Array.isArray(modelArgs) && Array.isArray(args)
|
|
347
|
+
&& args.some((arg) => typeof arg === 'string' && arg.includes('{model}'))) {
|
|
348
|
+
errors.push('put {model} in titleNamer.modelArgs or args, not both');
|
|
349
|
+
}
|
|
257
350
|
let models;
|
|
258
351
|
if (n.models !== undefined) {
|
|
259
352
|
if (typeof n.models !== 'object' || Array.isArray(n.models) || n.models === null) {
|
|
@@ -261,10 +354,11 @@ export function validateManifest(raw, dir) {
|
|
|
261
354
|
}
|
|
262
355
|
else {
|
|
263
356
|
const rawModels = n.models;
|
|
264
|
-
if (typeof rawModels.command !== 'string' || rawModels.command.trim().length === 0) {
|
|
357
|
+
if (typeof rawModels.command !== 'string' || rawModels.command.trim().length === 0 || rawModels.command.length > 1024) {
|
|
265
358
|
errors.push('titleNamer.models.command is required');
|
|
266
359
|
}
|
|
267
|
-
if (rawModels.args !== undefined && (!Array.isArray(rawModels.args) ||
|
|
360
|
+
if (rawModels.args !== undefined && (!Array.isArray(rawModels.args) || rawModels.args.length > 64
|
|
361
|
+
|| !rawModels.args.every((arg) => typeof arg === 'string' && arg.length <= 16_384))) {
|
|
268
362
|
errors.push('titleNamer.models.args must be a string array if present');
|
|
269
363
|
}
|
|
270
364
|
if (typeof rawModels.command === 'string') {
|
|
@@ -276,7 +370,12 @@ export function validateManifest(raw, dir) {
|
|
|
276
370
|
}
|
|
277
371
|
}
|
|
278
372
|
if (typeof command === 'string' && Array.isArray(args)) {
|
|
279
|
-
titleNamer = {
|
|
373
|
+
titleNamer = {
|
|
374
|
+
command: command.trim(),
|
|
375
|
+
args: args,
|
|
376
|
+
modelArgs: Array.isArray(modelArgs) ? modelArgs : undefined,
|
|
377
|
+
models,
|
|
378
|
+
};
|
|
280
379
|
}
|
|
281
380
|
}
|
|
282
381
|
}
|
|
@@ -348,18 +447,27 @@ export function loadPlugins() {
|
|
|
348
447
|
}
|
|
349
448
|
const iconPath = path.join(dir, ICON_FILE);
|
|
350
449
|
let iconMtime = 0;
|
|
351
|
-
|
|
450
|
+
let iconExists = fs.existsSync(iconPath);
|
|
352
451
|
if (iconExists) {
|
|
353
452
|
try {
|
|
354
|
-
|
|
453
|
+
if (!isSafeSvgIcon(fs.readFileSync(iconPath, 'utf8'))) {
|
|
454
|
+
errors.push({ slug: result.manifest.slug, errors: ['icon.svg contains unsafe or invalid SVG content'] });
|
|
455
|
+
iconExists = false;
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
iconMtime = fs.statSync(iconPath).mtimeMs;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
iconExists = false;
|
|
355
463
|
}
|
|
356
|
-
catch { /* keep 0 */ }
|
|
357
464
|
}
|
|
358
465
|
plugins.push({
|
|
359
466
|
manifest: result.manifest,
|
|
360
467
|
dir,
|
|
361
468
|
iconPath: iconExists ? iconPath : null,
|
|
362
469
|
iconMtime,
|
|
470
|
+
source: readPluginSourceMetadata(dir),
|
|
363
471
|
});
|
|
364
472
|
}
|
|
365
473
|
return { plugins, errors };
|
|
@@ -372,6 +480,17 @@ export function savePlugin(manifest) {
|
|
|
372
480
|
fs.mkdirSync(dir, { recursive: true });
|
|
373
481
|
const manifestPath = path.join(dir, MANIFEST_FILE);
|
|
374
482
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
483
|
+
const now = Date.now();
|
|
484
|
+
writePluginSourceMetadata(dir, {
|
|
485
|
+
version: 1,
|
|
486
|
+
type: 'manifest',
|
|
487
|
+
source: null,
|
|
488
|
+
revision: null,
|
|
489
|
+
latestRevision: null,
|
|
490
|
+
installedAt: now,
|
|
491
|
+
updatedAt: now,
|
|
492
|
+
checkedAt: null,
|
|
493
|
+
});
|
|
375
494
|
return dir;
|
|
376
495
|
}
|
|
377
496
|
/**
|