termdock 1.4.71 → 1.4.72

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 +36 -0
  2. package/dist/client/assets/{ModelPreview-CG6vLwyw.js → ModelPreview-i9dS1A3I.js} +1 -1
  3. package/dist/client/assets/{abnfDiagram-VRR7QNED-CrXEZLWX.js → abnfDiagram-VRR7QNED-Cs-ty-HA.js} +1 -1
  4. package/dist/client/assets/{arc-DbKGR7Ex.js → arc-CIX5k_JD.js} +1 -1
  5. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-PF5BoUbI.js → architectureDiagram-ZJ3FMSHR-DnPnQoJy.js} +1 -1
  6. package/dist/client/assets/{blockDiagram-677ZJIJ3-D1gRzVWB.js → blockDiagram-677ZJIJ3-Dhew-L5v.js} +1 -1
  7. package/dist/client/assets/{c4Diagram-LMCZKHZV-CwvOLync.js → c4Diagram-LMCZKHZV-BSsEMeWn.js} +1 -1
  8. package/dist/client/assets/channel-CASnt1pP.js +1 -0
  9. package/dist/client/assets/{chunk-2Q5K7J3B-BeRdP-xP.js → chunk-2Q5K7J3B-BWm--1Mo.js} +1 -1
  10. package/dist/client/assets/{chunk-32BRIVSS-CrqR5rvl.js → chunk-32BRIVSS-D_WFmuvb.js} +1 -1
  11. package/dist/client/assets/{chunk-5VM5RSS4-CjbUr-ko.js → chunk-5VM5RSS4-CQdzqlQg.js} +1 -1
  12. package/dist/client/assets/{chunk-EX3LRPZG-BVEk3Wma.js → chunk-EX3LRPZG-Cm3zLQpt.js} +1 -1
  13. package/dist/client/assets/{chunk-JWPE2WC7-WitJe8kR.js → chunk-JWPE2WC7-Cmcv5sEN.js} +1 -1
  14. package/dist/client/assets/{chunk-MOJQB5TN-BCewlLYk.js → chunk-MOJQB5TN-Bam44EwJ.js} +1 -1
  15. package/dist/client/assets/{chunk-RYQCIY6F-CoP0AA4G.js → chunk-RYQCIY6F-DIVOuE8y.js} +1 -1
  16. package/dist/client/assets/{chunk-V7JOEXUC-CSZrnmp_.js → chunk-V7JOEXUC-BfOTf7yg.js} +1 -1
  17. package/dist/client/assets/{chunk-VR4S4FIN-BB-7zz-9.js → chunk-VR4S4FIN-DbiXn0dd.js} +1 -1
  18. package/dist/client/assets/{chunk-XXDRQBXY-CcZZlCik.js → chunk-XXDRQBXY-C4pOe4OC.js} +1 -1
  19. package/dist/client/assets/classDiagram-OUVF2IWQ-Da_SQWEq.js +1 -0
  20. package/dist/client/assets/classDiagram-v2-EOCWNBFH-Da_SQWEq.js +1 -0
  21. package/dist/client/assets/{cose-bilkent-JH36ORCC-DcmzUDwh.js → cose-bilkent-JH36ORCC-CCoSPmoc.js} +1 -1
  22. package/dist/client/assets/{cynefin-VYW2F7L2-C1XlDVoJ.js → cynefin-VYW2F7L2-B2lYz7sl.js} +1 -1
  23. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-zY6Aet2S.js → cynefinDiagram-TSTJHNR4-Cd7crN9s.js} +1 -1
  24. package/dist/client/assets/{dagre-VKFMJZFB-DqGTi6zR.js → dagre-VKFMJZFB-q9Fj9HuQ.js} +1 -1
  25. package/dist/client/assets/{diagram-FQU43EPY-DStcsU7S.js → diagram-FQU43EPY-iqHy5YHW.js} +1 -1
  26. package/dist/client/assets/{diagram-G47NLZAW-Cs6IMNip.js → diagram-G47NLZAW-BE-jlh1B.js} +1 -1
  27. package/dist/client/assets/{diagram-NH7WQ7WH-yA2bpWN7.js → diagram-NH7WQ7WH-DmP7_WOC.js} +1 -1
  28. package/dist/client/assets/{diagram-OA4YK3LP-Dzf9M0PI.js → diagram-OA4YK3LP-CHx_k8Ar.js} +1 -1
  29. package/dist/client/assets/{diagram-WEI45ONY-D75lpQKA.js → diagram-WEI45ONY-D0wImpAF.js} +1 -1
  30. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-CkQvIpWy.js → ebnfDiagram-CCIWWBDH-DyCB0rF8.js} +1 -1
  31. package/dist/client/assets/{erDiagram-Q63AITRT-Ca00Vjxz.js → erDiagram-Q63AITRT-SRcd1B2Z.js} +1 -1
  32. package/dist/client/assets/{flowDiagram-23GEKE2U-CGrnSTEp.js → flowDiagram-23GEKE2U-D0i9mFwt.js} +1 -1
  33. package/dist/client/assets/{ganttDiagram-NO4QXBWP-0SHfOIP4.js → ganttDiagram-NO4QXBWP-BfwGIYDn.js} +1 -1
  34. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-BeigYWvs.js → gitGraphDiagram-IHSO6WYX-BXkJQP7O.js} +1 -1
  35. package/dist/client/assets/index-BA-DqJM2.js +451 -0
  36. package/dist/client/assets/{infoDiagram-FWYZ7A6U-CiY1-xjN.js → infoDiagram-FWYZ7A6U-CM-Ebv9R.js} +1 -1
  37. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-CmtDjUm_.js → ishikawaDiagram-FXEZZL3T-U-qnP7xi.js} +1 -1
  38. package/dist/client/assets/{journeyDiagram-5HDEW3XC-B7qOr9LY.js → journeyDiagram-5HDEW3XC-D4RxvsuL.js} +1 -1
  39. package/dist/client/assets/{kanban-definition-HUTT4EX6-DO2P2Um3.js → kanban-definition-HUTT4EX6-Dm_asCYK.js} +1 -1
  40. package/dist/client/assets/{linear-B832hJt0.js → linear-Zhe8d635.js} +1 -1
  41. package/dist/client/assets/{mermaid.core-CsAzdiCU.js → mermaid.core-D_NWIuMY.js} +4 -4
  42. package/dist/client/assets/{mindmap-definition-LN4V7U3C-HI__vXhQ.js → mindmap-definition-LN4V7U3C-DXpNidd5.js} +1 -1
  43. package/dist/client/assets/{pegDiagram-2B236MQR--rZIwjXq.js → pegDiagram-2B236MQR-DdOwOEgu.js} +1 -1
  44. package/dist/client/assets/{pieDiagram-ENE6RG2P-D9jPf5x9.js → pieDiagram-ENE6RG2P-D_A9HoY_.js} +1 -1
  45. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-DTCP5ji2.js → quadrantDiagram-ABIIQ3AL-D-Y3cLEF.js} +1 -1
  46. package/dist/client/assets/{railroadDiagram-RFXS5EU6-D2Gspvrl.js → railroadDiagram-RFXS5EU6-DD6eFV82.js} +1 -1
  47. package/dist/client/assets/{requirementDiagram-TGXJPOKE-DEJC02IW.js → requirementDiagram-TGXJPOKE-B59HJgL2.js} +1 -1
  48. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-opOhKPap.js → sankeyDiagram-HTMAVEWB-C2BvHMH8.js} +1 -1
  49. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-_sz2CRAG.js → sequenceDiagram-DBY2YBRQ-3004tCRm.js} +1 -1
  50. package/dist/client/assets/{sizeCapture-X5ZJPWSS-D0Q2QkYF.js → sizeCapture-X5ZJPWSS-DCBhg8QW.js} +1 -1
  51. package/dist/client/assets/{stateDiagram-2N3HPSRC-VgeSQTtW.js → stateDiagram-2N3HPSRC-BVqiKVe0.js} +1 -1
  52. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Z0GHWyoF.js +1 -0
  53. package/dist/client/assets/{swimlanes-5IMT3BWC-ClvBvSl0.js → swimlanes-5IMT3BWC-DS6-sdEW.js} +2 -2
  54. package/dist/client/assets/swimlanesDiagram-G3AALYLV-el4HDX0G.js +8 -0
  55. package/dist/client/assets/{timeline-definition-FHXFAJF6-BXNsrECV.js → timeline-definition-FHXFAJF6-wVOEmQwG.js} +1 -1
  56. package/dist/client/assets/{vennDiagram-L72KCM5P-BgwiqZd-.js → vennDiagram-L72KCM5P-DMZiSJDk.js} +1 -1
  57. package/dist/client/assets/{wardleyDiagram-EHGQE667-DiqN5ERx.js → wardleyDiagram-EHGQE667-Dx5Aob4Y.js} +1 -1
  58. package/dist/client/assets/{xychartDiagram-FW5EYKEG-DQjFVqC6.js → xychartDiagram-FW5EYKEG-BqMhkhmW.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 +123 -19
  64. package/dist/server/agent/titleNamerCatalog.js +39 -8
  65. package/dist/server/cli.js +205 -14
  66. package/dist/server/entry.js +11 -2
  67. package/dist/server/routes/terminal.js +232 -29
  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,10 +329,12 @@ 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
+ if (typeof command !== 'string' || command.trim().length === 0 || command.length > 1024) {
252
333
  errors.push('titleNamer.command is required');
253
334
  }
254
- if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string') || !args.some((arg) => arg.includes('{prompt}'))) {
335
+ if (!Array.isArray(args) || args.length > 64
336
+ || !args.every((arg) => typeof arg === 'string' && arg.length <= 16_384)
337
+ || !args.some((arg) => arg.includes('{prompt}'))) {
255
338
  errors.push('titleNamer.args must be a string array containing {prompt}');
256
339
  }
257
340
  let models;
@@ -261,10 +344,11 @@ export function validateManifest(raw, dir) {
261
344
  }
262
345
  else {
263
346
  const rawModels = n.models;
264
- if (typeof rawModels.command !== 'string' || rawModels.command.trim().length === 0) {
347
+ if (typeof rawModels.command !== 'string' || rawModels.command.trim().length === 0 || rawModels.command.length > 1024) {
265
348
  errors.push('titleNamer.models.command is required');
266
349
  }
267
- if (rawModels.args !== undefined && (!Array.isArray(rawModels.args) || !rawModels.args.every((arg) => typeof arg === 'string'))) {
350
+ if (rawModels.args !== undefined && (!Array.isArray(rawModels.args) || rawModels.args.length > 64
351
+ || !rawModels.args.every((arg) => typeof arg === 'string' && arg.length <= 16_384))) {
268
352
  errors.push('titleNamer.models.args must be a string array if present');
269
353
  }
270
354
  if (typeof rawModels.command === 'string') {
@@ -348,18 +432,27 @@ export function loadPlugins() {
348
432
  }
349
433
  const iconPath = path.join(dir, ICON_FILE);
350
434
  let iconMtime = 0;
351
- const iconExists = fs.existsSync(iconPath);
435
+ let iconExists = fs.existsSync(iconPath);
352
436
  if (iconExists) {
353
437
  try {
354
- iconMtime = fs.statSync(iconPath).mtimeMs;
438
+ if (!isSafeSvgIcon(fs.readFileSync(iconPath, 'utf8'))) {
439
+ errors.push({ slug: result.manifest.slug, errors: ['icon.svg contains unsafe or invalid SVG content'] });
440
+ iconExists = false;
441
+ }
442
+ else {
443
+ iconMtime = fs.statSync(iconPath).mtimeMs;
444
+ }
445
+ }
446
+ catch {
447
+ iconExists = false;
355
448
  }
356
- catch { /* keep 0 */ }
357
449
  }
358
450
  plugins.push({
359
451
  manifest: result.manifest,
360
452
  dir,
361
453
  iconPath: iconExists ? iconPath : null,
362
454
  iconMtime,
455
+ source: readPluginSourceMetadata(dir),
363
456
  });
364
457
  }
365
458
  return { plugins, errors };
@@ -372,6 +465,17 @@ export function savePlugin(manifest) {
372
465
  fs.mkdirSync(dir, { recursive: true });
373
466
  const manifestPath = path.join(dir, MANIFEST_FILE);
374
467
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
468
+ const now = Date.now();
469
+ writePluginSourceMetadata(dir, {
470
+ version: 1,
471
+ type: 'manifest',
472
+ source: null,
473
+ revision: null,
474
+ latestRevision: null,
475
+ installedAt: now,
476
+ updatedAt: now,
477
+ checkedAt: null,
478
+ });
375
479
  return dir;
376
480
  }
377
481
  /**
@@ -4,10 +4,24 @@ import os from 'os';
4
4
  import path from 'path';
5
5
  import { promisify } from 'util';
6
6
  import { loadPlugins } from './plugins.js';
7
+ import { getAutoRenameAgentsSetting, getAutoRenameNamerSetting } from '../utils/settings.js';
7
8
  const execFileAsync = promisify(execFile);
8
9
  const CACHE_FRESH_MS = 24 * 60 * 60_000;
9
10
  const PROBE_TIMEOUT_MS = 10_000;
10
11
  const CACHE_FILE = path.join(os.homedir(), '.termdock', 'title-namer-catalog.json');
12
+ function pluginCommandEnv() {
13
+ const allowed = ['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'TERM', 'TMPDIR', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'XDG_DATA_HOME'];
14
+ const env = { NO_COLOR: '1', TERM: 'dumb' };
15
+ for (const key of allowed) {
16
+ if (process.env[key] !== undefined)
17
+ env[key] = process.env[key];
18
+ }
19
+ for (const [key, value] of Object.entries(process.env)) {
20
+ if (key.startsWith('LC_') && value !== undefined)
21
+ env[key] = value;
22
+ }
23
+ return env;
24
+ }
11
25
  let cached = null;
12
26
  let refreshPromise = null;
13
27
  export function invalidateTitleNamerCatalog() {
@@ -179,13 +193,17 @@ function normalizeDiscoveredModels(input) {
179
193
  }];
180
194
  });
181
195
  }
182
- async function listPluginModels(config) {
196
+ function expandPluginPath(value, pluginDir) {
197
+ return value.replaceAll('{pluginDir}', pluginDir);
198
+ }
199
+ async function listPluginModels(config, pluginDir) {
183
200
  if (!config.models)
184
201
  return [];
185
- const { stdout } = await execFileAsync(config.models.command, config.models.args ?? [], {
202
+ const { stdout } = await execFileAsync(expandPluginPath(config.models.command, pluginDir), (config.models.args ?? []).map((arg) => expandPluginPath(arg, pluginDir)), {
186
203
  timeout: PROBE_TIMEOUT_MS,
187
204
  maxBuffer: 256 * 1024,
188
- env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
205
+ cwd: pluginDir,
206
+ env: pluginCommandEnv(),
189
207
  });
190
208
  return normalizeDiscoveredModels(JSON.parse(stdout));
191
209
  }
@@ -195,12 +213,21 @@ function pluginTitleNamers() {
195
213
  slug: plugin.manifest.slug,
196
214
  displayName: plugin.manifest.displayName,
197
215
  config: plugin.manifest.titleNamer,
216
+ dir: plugin.dir,
198
217
  }]
199
218
  : []);
200
219
  }
220
+ export function shouldRunPluginTitleCommands(slug, selected, enabledAgents) {
221
+ return selected === slug
222
+ || (selected === 'auto' && enabledAgents.includes(slug));
223
+ }
224
+ function isPluginTitleExecutionEnabled(slug) {
225
+ return shouldRunPluginTitleCommands(slug, getAutoRenameNamerSetting(), getAutoRenameAgentsSetting());
226
+ }
201
227
  export async function runTitleNamer(slug, prompt, model) {
202
228
  let command;
203
229
  let args;
230
+ let cwd;
204
231
  if (slug === 'codex') {
205
232
  command = 'codex';
206
233
  args = ['exec', '--ephemeral', '--skip-git-repo-check', '--color', 'never', ...(model ? ['--model', model] : []), prompt];
@@ -211,20 +238,22 @@ export async function runTitleNamer(slug, prompt, model) {
211
238
  }
212
239
  else {
213
240
  const provider = pluginTitleNamers().find((entry) => entry.slug === slug);
214
- if (!provider)
241
+ if (!provider || !isPluginTitleExecutionEnabled(slug))
215
242
  return null;
216
- command = provider.config.command;
243
+ cwd = provider.dir;
244
+ command = expandPluginPath(provider.config.command, provider.dir);
217
245
  args = provider.config.args.flatMap((arg) => {
218
246
  if (arg.includes('{model}') && !model)
219
247
  return [];
220
- return [arg.replaceAll('{prompt}', prompt).replaceAll('{model}', model ?? '')];
248
+ return [expandPluginPath(arg, provider.dir).replaceAll('{prompt}', prompt).replaceAll('{model}', model ?? '')];
221
249
  });
222
250
  }
223
251
  try {
224
252
  const { stdout } = await execFileAsync(command, args, {
225
253
  timeout: 45_000,
226
254
  maxBuffer: 256 * 1024,
227
- env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
255
+ cwd,
256
+ env: cwd ? pluginCommandEnv() : { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
228
257
  });
229
258
  return stdout;
230
259
  }
@@ -240,7 +269,9 @@ async function refreshTitleNamerCatalog() {
240
269
  const [codex, claude, ...pluginResults] = await Promise.allSettled([
241
270
  listCodexModels(),
242
271
  listClaudeModels(),
243
- ...plugins.map((provider) => listPluginModels(provider.config)),
272
+ ...plugins.map((provider) => provider.config.models && isPluginTitleExecutionEnabled(provider.slug)
273
+ ? listPluginModels(provider.config, provider.dir)
274
+ : Promise.resolve([])),
244
275
  ]);
245
276
  const previousCodex = cached?.value.find((namer) => namer.slug === 'codex');
246
277
  const previousClaude = cached?.value.find((namer) => namer.slug === 'claude');