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.
Files changed (77) hide show
  1. package/README.md +59 -0
  2. package/dist/client/assets/{ModelPreview-CG6vLwyw.js → ModelPreview-BYS46cJp.js} +1 -1
  3. package/dist/client/assets/{abnfDiagram-VRR7QNED-CrXEZLWX.js → abnfDiagram-VRR7QNED-Df5iizBM.js} +1 -1
  4. package/dist/client/assets/{arc-DbKGR7Ex.js → arc-BRLIrHaj.js} +1 -1
  5. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-PF5BoUbI.js → architectureDiagram-ZJ3FMSHR-BmIKhrrq.js} +1 -1
  6. package/dist/client/assets/{blockDiagram-677ZJIJ3-D1gRzVWB.js → blockDiagram-677ZJIJ3-PBtq2qLG.js} +1 -1
  7. package/dist/client/assets/{c4Diagram-LMCZKHZV-CwvOLync.js → c4Diagram-LMCZKHZV-6pevynP4.js} +1 -1
  8. package/dist/client/assets/channel-CfecnPVa.js +1 -0
  9. package/dist/client/assets/{chunk-2Q5K7J3B-BeRdP-xP.js → chunk-2Q5K7J3B-78EZViva.js} +1 -1
  10. package/dist/client/assets/{chunk-32BRIVSS-CrqR5rvl.js → chunk-32BRIVSS-CekczK5l.js} +1 -1
  11. package/dist/client/assets/{chunk-5VM5RSS4-CjbUr-ko.js → chunk-5VM5RSS4-fMhlGbVA.js} +1 -1
  12. package/dist/client/assets/{chunk-EX3LRPZG-BVEk3Wma.js → chunk-EX3LRPZG-DOBewV4Z.js} +1 -1
  13. package/dist/client/assets/{chunk-JWPE2WC7-WitJe8kR.js → chunk-JWPE2WC7-rtk-mGC7.js} +1 -1
  14. package/dist/client/assets/{chunk-MOJQB5TN-BCewlLYk.js → chunk-MOJQB5TN-CWUZocq_.js} +1 -1
  15. package/dist/client/assets/{chunk-RYQCIY6F-CoP0AA4G.js → chunk-RYQCIY6F-DcHAjeyV.js} +1 -1
  16. package/dist/client/assets/{chunk-V7JOEXUC-CSZrnmp_.js → chunk-V7JOEXUC-C2paDSKz.js} +1 -1
  17. package/dist/client/assets/{chunk-VR4S4FIN-BB-7zz-9.js → chunk-VR4S4FIN-Cq0ZrFUq.js} +1 -1
  18. package/dist/client/assets/{chunk-XXDRQBXY-CcZZlCik.js → chunk-XXDRQBXY-BboySYmP.js} +1 -1
  19. package/dist/client/assets/classDiagram-OUVF2IWQ-CFpMarVm.js +1 -0
  20. package/dist/client/assets/classDiagram-v2-EOCWNBFH-CFpMarVm.js +1 -0
  21. package/dist/client/assets/{cose-bilkent-JH36ORCC-DcmzUDwh.js → cose-bilkent-JH36ORCC-CMnHIr6y.js} +1 -1
  22. package/dist/client/assets/{cynefin-VYW2F7L2-C1XlDVoJ.js → cynefin-VYW2F7L2-BpOtLuYh.js} +1 -1
  23. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-zY6Aet2S.js → cynefinDiagram-TSTJHNR4-CmrMqA7z.js} +1 -1
  24. package/dist/client/assets/{dagre-VKFMJZFB-DqGTi6zR.js → dagre-VKFMJZFB-iXOV-rm7.js} +1 -1
  25. package/dist/client/assets/{diagram-FQU43EPY-DStcsU7S.js → diagram-FQU43EPY-D5KagnmX.js} +1 -1
  26. package/dist/client/assets/{diagram-G47NLZAW-Cs6IMNip.js → diagram-G47NLZAW-CglgkfyY.js} +1 -1
  27. package/dist/client/assets/{diagram-NH7WQ7WH-yA2bpWN7.js → diagram-NH7WQ7WH-Dos-y1pl.js} +1 -1
  28. package/dist/client/assets/{diagram-OA4YK3LP-Dzf9M0PI.js → diagram-OA4YK3LP-Cw6yJqFX.js} +1 -1
  29. package/dist/client/assets/{diagram-WEI45ONY-D75lpQKA.js → diagram-WEI45ONY-C_MHHYRA.js} +1 -1
  30. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-CkQvIpWy.js → ebnfDiagram-CCIWWBDH-BB7x8X2l.js} +1 -1
  31. package/dist/client/assets/{erDiagram-Q63AITRT-Ca00Vjxz.js → erDiagram-Q63AITRT-BznrrU3P.js} +1 -1
  32. package/dist/client/assets/{flowDiagram-23GEKE2U-CGrnSTEp.js → flowDiagram-23GEKE2U-CMzPvwGa.js} +1 -1
  33. package/dist/client/assets/{ganttDiagram-NO4QXBWP-0SHfOIP4.js → ganttDiagram-NO4QXBWP-Dc9JCHv3.js} +1 -1
  34. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-BeigYWvs.js → gitGraphDiagram-IHSO6WYX-WlPpmZQm.js} +1 -1
  35. package/dist/client/assets/index-wX3Y6Ks7.js +451 -0
  36. package/dist/client/assets/{infoDiagram-FWYZ7A6U-CiY1-xjN.js → infoDiagram-FWYZ7A6U-DC6PBJiN.js} +1 -1
  37. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-CmtDjUm_.js → ishikawaDiagram-FXEZZL3T-DPfxPu0W.js} +1 -1
  38. package/dist/client/assets/{journeyDiagram-5HDEW3XC-B7qOr9LY.js → journeyDiagram-5HDEW3XC-aA2ovr_f.js} +1 -1
  39. package/dist/client/assets/{kanban-definition-HUTT4EX6-DO2P2Um3.js → kanban-definition-HUTT4EX6-_T30f7Or.js} +1 -1
  40. package/dist/client/assets/{linear-B832hJt0.js → linear-DRZcO7_m.js} +1 -1
  41. package/dist/client/assets/{mermaid.core-CsAzdiCU.js → mermaid.core-BGXjYfdb.js} +4 -4
  42. package/dist/client/assets/{mindmap-definition-LN4V7U3C-HI__vXhQ.js → mindmap-definition-LN4V7U3C-Bv0lhpqs.js} +1 -1
  43. package/dist/client/assets/{pegDiagram-2B236MQR--rZIwjXq.js → pegDiagram-2B236MQR-D-EIIOZf.js} +1 -1
  44. package/dist/client/assets/{pieDiagram-ENE6RG2P-D9jPf5x9.js → pieDiagram-ENE6RG2P-CATAcPgl.js} +1 -1
  45. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-DTCP5ji2.js → quadrantDiagram-ABIIQ3AL-2tglHVSk.js} +1 -1
  46. package/dist/client/assets/{railroadDiagram-RFXS5EU6-D2Gspvrl.js → railroadDiagram-RFXS5EU6-BMKC-e8R.js} +1 -1
  47. package/dist/client/assets/{requirementDiagram-TGXJPOKE-DEJC02IW.js → requirementDiagram-TGXJPOKE-B5J5_wFW.js} +1 -1
  48. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-opOhKPap.js → sankeyDiagram-HTMAVEWB-Djlm71ae.js} +1 -1
  49. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-_sz2CRAG.js → sequenceDiagram-DBY2YBRQ-BtR8OPSJ.js} +1 -1
  50. package/dist/client/assets/{sizeCapture-X5ZJPWSS-D0Q2QkYF.js → sizeCapture-X5ZJPWSS-CDelD9h_.js} +1 -1
  51. package/dist/client/assets/{stateDiagram-2N3HPSRC-VgeSQTtW.js → stateDiagram-2N3HPSRC-C50kGru1.js} +1 -1
  52. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-gIr1L4WZ.js +1 -0
  53. package/dist/client/assets/{swimlanes-5IMT3BWC-ClvBvSl0.js → swimlanes-5IMT3BWC-COHxl86-.js} +2 -2
  54. package/dist/client/assets/swimlanesDiagram-G3AALYLV-COE4p0kX.js +8 -0
  55. package/dist/client/assets/{timeline-definition-FHXFAJF6-BXNsrECV.js → timeline-definition-FHXFAJF6-br-VI8Fd.js} +1 -1
  56. package/dist/client/assets/{vennDiagram-L72KCM5P-BgwiqZd-.js → vennDiagram-L72KCM5P-zwMtEv81.js} +1 -1
  57. package/dist/client/assets/{wardleyDiagram-EHGQE667-DiqN5ERx.js → wardleyDiagram-EHGQE667-_C8v4nnr.js} +1 -1
  58. package/dist/client/assets/{xychartDiagram-FW5EYKEG-DQjFVqC6.js → xychartDiagram-FW5EYKEG-CSU2R6pt.js} +1 -1
  59. package/dist/client/index.html +1 -1
  60. package/dist/client/sw.js +1 -1
  61. package/dist/server/agent/autoTitle.js +3 -0
  62. package/dist/server/agent/pluginPackages.js +289 -0
  63. package/dist/server/agent/plugins.js +139 -20
  64. package/dist/server/agent/titleNamerCatalog.js +235 -40
  65. package/dist/server/cli.js +277 -17
  66. package/dist/server/entry.js +11 -2
  67. package/dist/server/routes/terminal.js +241 -30
  68. package/dist/server/utils/csrfProtection.js +4 -1
  69. package/dist/server/utils/settings.js +14 -0
  70. package/package.json +1 -1
  71. package/runtime-manifest.json +2 -2
  72. package/dist/client/assets/channel-JTEuKVy6.js +0 -1
  73. package/dist/client/assets/classDiagram-OUVF2IWQ-BiFJB_5s.js +0 -1
  74. package/dist/client/assets/classDiagram-v2-EOCWNBFH-BiFJB_5s.js +0 -1
  75. package/dist/client/assets/index-D5PKdbhW.js +0 -451
  76. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BVh2FRO-.js +0 -1
  77. 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 || !aliases.every((a) => typeof a === 'string' && a.trim().length > 0)) {
94
- errors.push('aliases must be a non-empty array of strings');
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) || !staleFlags.every((f) => typeof f === 'string'))) {
231
- errors.push('resume.staleFlags must be an array of strings if provided');
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
- if (typeof command !== 'string' || command.trim().length === 0) {
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) || !args.every((arg) => typeof arg === 'string') || !args.some((arg) => arg.includes('{prompt}'))) {
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) || !rawModels.args.every((arg) => typeof arg === 'string'))) {
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 = { command: command.trim(), args: args, models };
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
- const iconExists = fs.existsSync(iconPath);
450
+ let iconExists = fs.existsSync(iconPath);
352
451
  if (iconExists) {
353
452
  try {
354
- iconMtime = fs.statSync(iconPath).mtimeMs;
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
  /**