termdock 1.4.70 → 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 (81) hide show
  1. package/README.md +36 -0
  2. package/dist/client/assets/{ModelPreview-Cs14IQuQ.js → ModelPreview-i9dS1A3I.js} +1 -1
  3. package/dist/client/assets/{abnfDiagram-VRR7QNED-DuDfdv-6.js → abnfDiagram-VRR7QNED-Cs-ty-HA.js} +1 -1
  4. package/dist/client/assets/{arc-CSYzGV_2.js → arc-CIX5k_JD.js} +1 -1
  5. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-YiZ0HXNi.js → architectureDiagram-ZJ3FMSHR-DnPnQoJy.js} +1 -1
  6. package/dist/client/assets/{blockDiagram-677ZJIJ3-IaQMzmYv.js → blockDiagram-677ZJIJ3-Dhew-L5v.js} +1 -1
  7. package/dist/client/assets/{c4Diagram-LMCZKHZV-BwyuIPRL.js → c4Diagram-LMCZKHZV-BSsEMeWn.js} +1 -1
  8. package/dist/client/assets/channel-CASnt1pP.js +1 -0
  9. package/dist/client/assets/{chunk-2Q5K7J3B-BtHQYLBT.js → chunk-2Q5K7J3B-BWm--1Mo.js} +1 -1
  10. package/dist/client/assets/{chunk-32BRIVSS-CXrK07sW.js → chunk-32BRIVSS-D_WFmuvb.js} +1 -1
  11. package/dist/client/assets/{chunk-5VM5RSS4-BfBshn11.js → chunk-5VM5RSS4-CQdzqlQg.js} +1 -1
  12. package/dist/client/assets/{chunk-EX3LRPZG-BJbilNaf.js → chunk-EX3LRPZG-Cm3zLQpt.js} +1 -1
  13. package/dist/client/assets/{chunk-JWPE2WC7-wgcmSRlL.js → chunk-JWPE2WC7-Cmcv5sEN.js} +1 -1
  14. package/dist/client/assets/{chunk-MOJQB5TN-8VZVUeDI.js → chunk-MOJQB5TN-Bam44EwJ.js} +1 -1
  15. package/dist/client/assets/{chunk-RYQCIY6F-B900YBFa.js → chunk-RYQCIY6F-DIVOuE8y.js} +1 -1
  16. package/dist/client/assets/{chunk-V7JOEXUC-BK4_RLz-.js → chunk-V7JOEXUC-BfOTf7yg.js} +1 -1
  17. package/dist/client/assets/{chunk-VR4S4FIN-Kj5_ycjB.js → chunk-VR4S4FIN-DbiXn0dd.js} +1 -1
  18. package/dist/client/assets/{chunk-XXDRQBXY-BolvPLRz.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-9_9_SLqv.js → cose-bilkent-JH36ORCC-CCoSPmoc.js} +1 -1
  22. package/dist/client/assets/{cynefin-VYW2F7L2-yEpoi3vG.js → cynefin-VYW2F7L2-B2lYz7sl.js} +1 -1
  23. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-Dy-gvup_.js → cynefinDiagram-TSTJHNR4-Cd7crN9s.js} +1 -1
  24. package/dist/client/assets/{dagre-VKFMJZFB-CNjJ5pRK.js → dagre-VKFMJZFB-q9Fj9HuQ.js} +1 -1
  25. package/dist/client/assets/{diagram-FQU43EPY-uZ73Rj3T.js → diagram-FQU43EPY-iqHy5YHW.js} +1 -1
  26. package/dist/client/assets/{diagram-G47NLZAW-Cxt7TYZT.js → diagram-G47NLZAW-BE-jlh1B.js} +1 -1
  27. package/dist/client/assets/{diagram-NH7WQ7WH-B4TKinuZ.js → diagram-NH7WQ7WH-DmP7_WOC.js} +1 -1
  28. package/dist/client/assets/{diagram-OA4YK3LP-CGSuenHU.js → diagram-OA4YK3LP-CHx_k8Ar.js} +1 -1
  29. package/dist/client/assets/{diagram-WEI45ONY-DqqzDaUZ.js → diagram-WEI45ONY-D0wImpAF.js} +1 -1
  30. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-B2SgXL5w.js → ebnfDiagram-CCIWWBDH-DyCB0rF8.js} +1 -1
  31. package/dist/client/assets/{erDiagram-Q63AITRT-D-ibkr8y.js → erDiagram-Q63AITRT-SRcd1B2Z.js} +1 -1
  32. package/dist/client/assets/{flowDiagram-23GEKE2U-BPeyMqr-.js → flowDiagram-23GEKE2U-D0i9mFwt.js} +1 -1
  33. package/dist/client/assets/{ganttDiagram-NO4QXBWP-DcEqEzzu.js → ganttDiagram-NO4QXBWP-BfwGIYDn.js} +1 -1
  34. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-DTZ6UCQY.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-BHrzVXeX.js → infoDiagram-FWYZ7A6U-CM-Ebv9R.js} +1 -1
  37. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-CwuoQJku.js → ishikawaDiagram-FXEZZL3T-U-qnP7xi.js} +1 -1
  38. package/dist/client/assets/{journeyDiagram-5HDEW3XC-ZEcy0rDZ.js → journeyDiagram-5HDEW3XC-D4RxvsuL.js} +1 -1
  39. package/dist/client/assets/{kanban-definition-HUTT4EX6-BDBawNbr.js → kanban-definition-HUTT4EX6-Dm_asCYK.js} +1 -1
  40. package/dist/client/assets/{linear-smYQWzDV.js → linear-Zhe8d635.js} +1 -1
  41. package/dist/client/assets/{mermaid.core-eJs7Ouvw.js → mermaid.core-D_NWIuMY.js} +4 -4
  42. package/dist/client/assets/{mindmap-definition-LN4V7U3C-BK1IZ9zg.js → mindmap-definition-LN4V7U3C-DXpNidd5.js} +1 -1
  43. package/dist/client/assets/{pegDiagram-2B236MQR-B3-mVNgX.js → pegDiagram-2B236MQR-DdOwOEgu.js} +1 -1
  44. package/dist/client/assets/{pieDiagram-ENE6RG2P-Bkeeyx5l.js → pieDiagram-ENE6RG2P-D_A9HoY_.js} +1 -1
  45. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-DouIklEO.js → quadrantDiagram-ABIIQ3AL-D-Y3cLEF.js} +1 -1
  46. package/dist/client/assets/{railroadDiagram-RFXS5EU6-BB_jXQd2.js → railroadDiagram-RFXS5EU6-DD6eFV82.js} +1 -1
  47. package/dist/client/assets/{requirementDiagram-TGXJPOKE-u56IHfiR.js → requirementDiagram-TGXJPOKE-B59HJgL2.js} +1 -1
  48. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-eDi2cs5E.js → sankeyDiagram-HTMAVEWB-C2BvHMH8.js} +1 -1
  49. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-DnAA2xYa.js → sequenceDiagram-DBY2YBRQ-3004tCRm.js} +1 -1
  50. package/dist/client/assets/{sizeCapture-X5ZJPWSS-BogPui79.js → sizeCapture-X5ZJPWSS-DCBhg8QW.js} +1 -1
  51. package/dist/client/assets/{stateDiagram-2N3HPSRC--EeXDy2M.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-DZAw7-kY.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-DkcnpB-Y.js → timeline-definition-FHXFAJF6-wVOEmQwG.js} +1 -1
  56. package/dist/client/assets/{vennDiagram-L72KCM5P-BStcDGEf.js → vennDiagram-L72KCM5P-DMZiSJDk.js} +1 -1
  57. package/dist/client/assets/{wardleyDiagram-EHGQE667-Cj4CQn1M.js → wardleyDiagram-EHGQE667-Dx5Aob4Y.js} +1 -1
  58. package/dist/client/assets/{xychartDiagram-FW5EYKEG-_uBCjai8.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 +16 -36
  62. package/dist/server/agent/installers.js +98 -15
  63. package/dist/server/agent/pluginPackages.js +289 -0
  64. package/dist/server/agent/plugins.js +164 -15
  65. package/dist/server/agent/registry.js +1 -1
  66. package/dist/server/agent/session.js +10 -1
  67. package/dist/server/agent/titleNamerCatalog.js +131 -2
  68. package/dist/server/agentHook.js +47 -0
  69. package/dist/server/cli.js +224 -13
  70. package/dist/server/entry.js +11 -2
  71. package/dist/server/routes/terminal.js +267 -37
  72. package/dist/server/utils/csrfProtection.js +4 -1
  73. package/dist/server/utils/settings.js +18 -5
  74. package/package.json +1 -1
  75. package/runtime-manifest.json +2 -2
  76. package/dist/client/assets/channel-BotxtiNE.js +0 -1
  77. package/dist/client/assets/classDiagram-OUVF2IWQ-CLWlbTSC.js +0 -1
  78. package/dist/client/assets/classDiagram-v2-EOCWNBFH-CLWlbTSC.js +0 -1
  79. package/dist/client/assets/index-ivnGnbVg.js +0 -451
  80. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Dc-3HKTs.js +0 -1
  81. package/dist/client/assets/swimlanesDiagram-G3AALYLV-CI1Wlr0V.js +0 -8
@@ -16,7 +16,7 @@
16
16
  import fs from 'node:fs';
17
17
  import os from 'node:os';
18
18
  import path from 'node:path';
19
- import { execFile } from 'node:child_process';
19
+ import { execFile, spawn } from 'node:child_process';
20
20
  import { agentBySlug, isPluginAgent } from './registry.js';
21
21
  import { loadPlugins, resolveHookTarget } from './plugins.js';
22
22
  export const HOOK_AGENTS = ['claude', 'codex', 'copilot', 'opencode', 'pi', 'grok', 'kimi'];
@@ -121,13 +121,14 @@ const CLAUDE_HOOK_EVENTS = [
121
121
  ['Stop', 'stop'],
122
122
  ['SessionEnd', 'session-end'],
123
123
  ];
124
- /** Codex's hook events (~/.codex/hooks.json, Claude-shaped). Codex is
125
- * turn-level only: no Notification hook, and no SessionEnd — the pane's
126
- * foreground detection clears the badge when Codex exits. */
124
+ /** Codex's native hooks.json event vocabulary. */
127
125
  const CODEX_HOOK_EVENTS = [
128
126
  ['SessionStart', 'session-start'],
129
127
  ['UserPromptSubmit', 'prompt-submit'],
128
+ ['PermissionRequest', 'permission-request'],
129
+ ['PostToolUse', 'tool-complete'],
130
130
  ['Stop', 'stop'],
131
+ ['SessionEnd', 'session-end'],
131
132
  ];
132
133
  /**
133
134
  * Kimi Code's hook events (~/.kimi-code/config.toml `[[hooks]]` entries, TOML).
@@ -620,6 +621,73 @@ export function hooksState(agent) {
620
621
  return 'not-installed';
621
622
  return ownedFileState(file, expected, marker(agent));
622
623
  }
624
+ let codexRuntimeStateCache = null;
625
+ /** Query Codex's own hook registry: a structurally present hook can still be
626
+ * quarantined until the user approves its current hash in `/hooks`. */
627
+ async function codexRuntimeHooksState() {
628
+ const structural = hooksState('codex');
629
+ if (structural !== 'installed')
630
+ return structural;
631
+ if (codexRuntimeStateCache && Date.now() - codexRuntimeStateCache.at < 30_000) {
632
+ return codexRuntimeStateCache.state;
633
+ }
634
+ const state = await new Promise((resolve) => {
635
+ const child = spawn('codex', ['app-server', '--stdio'], {
636
+ stdio: ['pipe', 'pipe', 'ignore'],
637
+ env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
638
+ });
639
+ let buffer = '';
640
+ let settled = false;
641
+ const finish = (value) => {
642
+ if (settled)
643
+ return;
644
+ settled = true;
645
+ clearTimeout(timer);
646
+ child.kill();
647
+ resolve(value);
648
+ };
649
+ const timer = setTimeout(() => finish(structural), 5_000);
650
+ timer.unref?.();
651
+ child.on('error', () => finish(structural));
652
+ child.stdout.on('data', (chunk) => {
653
+ buffer += chunk.toString();
654
+ for (;;) {
655
+ const newline = buffer.indexOf('\n');
656
+ if (newline < 0)
657
+ break;
658
+ const line = buffer.slice(0, newline).trim();
659
+ buffer = buffer.slice(newline + 1);
660
+ if (!line)
661
+ continue;
662
+ let message;
663
+ try {
664
+ message = JSON.parse(line);
665
+ }
666
+ catch {
667
+ continue;
668
+ }
669
+ if (message.id === 1) {
670
+ child.stdin.write(`${JSON.stringify({ method: 'initialized', params: {} })}\n`);
671
+ child.stdin.write(`${JSON.stringify({ id: 2, method: 'hooks/list', params: { cwds: [process.cwd()] } })}\n`);
672
+ }
673
+ else if (message.id === 2) {
674
+ const ours = (message.result?.data ?? [])
675
+ .flatMap((entry) => entry.hooks ?? [])
676
+ .filter((hook) => typeof hook.command === 'string' && hook.command.includes(marker('codex')));
677
+ if (ours.length < CODEX_HOOK_EVENTS.length)
678
+ finish('outdated');
679
+ else if (ours.some((hook) => hook.enabled === false || !['trusted', 'managed'].includes(hook.trustStatus ?? '')))
680
+ finish('needs-approval');
681
+ else
682
+ finish('installed');
683
+ }
684
+ }
685
+ });
686
+ child.stdin.write(`${JSON.stringify({ id: 1, method: 'initialize', params: { clientInfo: { name: 'termdock', version: '1' } } })}\n`);
687
+ });
688
+ codexRuntimeStateCache = { at: Date.now(), state };
689
+ return state;
690
+ }
623
691
  export function listHookAgents() {
624
692
  return HOOK_AGENTS.map((slug) => ({
625
693
  slug,
@@ -641,9 +709,10 @@ export async function installHooks(agent) {
641
709
  }
642
710
  if (agent === 'codex') {
643
711
  hookMapInstall(file, agent, CODEX_HOOK_EVENTS);
712
+ codexRuntimeStateCache = null;
644
713
  try {
645
714
  await enableCodexHooksFeature();
646
- return { summary: 'Installed', devMode };
715
+ return { summary: 'Installed — open /hooks in Codex and approve any new Termdock hooks', devMode };
647
716
  }
648
717
  catch (error) {
649
718
  return {
@@ -668,6 +737,8 @@ export async function installHooks(agent) {
668
737
  export async function uninstallHooks(agent) {
669
738
  const file = targetPath(agent);
670
739
  if (agent === 'claude' || agent === 'codex') {
740
+ if (agent === 'codex')
741
+ codexRuntimeStateCache = null;
671
742
  return hookMapUninstall(file, agent);
672
743
  }
673
744
  if (agent === 'kimi') {
@@ -700,7 +771,7 @@ function hookCommandForPlugin(slug, event, status) {
700
771
  * List all agents with hook support — both built-in and plugin-defined.
701
772
  * Plugin agents appear after built-in agents.
702
773
  */
703
- export function listAllHookAgents() {
774
+ export async function listAllHookAgents() {
704
775
  const builtIn = HOOK_AGENTS.map((slug) => {
705
776
  const agent = agentBySlug(slug);
706
777
  return {
@@ -727,6 +798,9 @@ export function listAllHookAgents() {
727
798
  iconVersion: agent?.iconVersion ?? null,
728
799
  };
729
800
  });
801
+ const codex = builtIn.find((entry) => entry.slug === 'codex');
802
+ if (codex)
803
+ codex.state = await codexRuntimeHooksState();
730
804
  return [...builtIn, ...pluginAgents];
731
805
  }
732
806
  export function hooksStateForSlug(slug) {
@@ -763,7 +837,7 @@ export async function uninstallHooksForSlug(slug) {
763
837
  // Generic hookmap logic (shared with built-in Claude/Codex + all plugins)
764
838
  // ---------------------------------------------------------------------------
765
839
  /** Generic state check for a hookmap-style hooks file. */
766
- function pluginHooksState(slug, file, events) {
840
+ export function pluginHooksState(slug, file, events) {
767
841
  const root = readJsonObject(file);
768
842
  if (!root)
769
843
  return 'not-installed';
@@ -771,16 +845,22 @@ function pluginHooksState(slug, file, events) {
771
845
  const hooks = root.hooks;
772
846
  let any = false;
773
847
  let complete = true;
774
- for (const [hookEvent, sentinel, , , status] of events) {
848
+ for (const [hookEvent, sentinel, matcher, timeout, status] of events) {
775
849
  const list = hooks && typeof hooks === 'object'
776
850
  ? hooks[hookEvent]
777
851
  : null;
778
852
  const ours = Array.isArray(list)
779
- ? list.map((entry) => markerCommand(entry, mark)).find((c) => c !== null) ?? null
780
- : null;
781
- if (ours !== null) {
853
+ ? list.find((entry) => markerCommand(entry, mark) !== null)
854
+ : undefined;
855
+ if (ours !== undefined) {
782
856
  any = true;
783
- if (ours !== hookCommandForPlugin(slug, sentinel, status))
857
+ const group = ours && typeof ours === 'object' ? ours : {};
858
+ const handler = Array.isArray(group.hooks) && group.hooks[0] && typeof group.hooks[0] === 'object'
859
+ ? group.hooks[0]
860
+ : {};
861
+ if (handler.command !== hookCommandForPlugin(slug, sentinel, status)
862
+ || (matcher ?? undefined) !== group.matcher
863
+ || (timeout ?? undefined) !== handler.timeout)
784
864
  complete = false;
785
865
  }
786
866
  else {
@@ -792,7 +872,7 @@ function pluginHooksState(slug, file, events) {
792
872
  return complete ? 'installed' : 'outdated';
793
873
  }
794
874
  /** Generic install for a hookmap-style hooks file. */
795
- function installPluginHooks(slug, file, events) {
875
+ export function installPluginHooks(slug, file, events) {
796
876
  const { devMode } = resolveHookScript();
797
877
  let root = {};
798
878
  try {
@@ -815,7 +895,7 @@ function installPluginHooks(slug, file, events) {
815
895
  }
816
896
  const hooks = root.hooks;
817
897
  const mark = marker(slug);
818
- for (const [hookEvent, sentinel, matcher, , status] of events) {
898
+ for (const [hookEvent, sentinel, matcher, timeout, status] of events) {
819
899
  const command = hookCommandForPlugin(slug, sentinel, status);
820
900
  let list = hooks[hookEvent];
821
901
  if (list === undefined) {
@@ -825,7 +905,10 @@ function installPluginHooks(slug, file, events) {
825
905
  if (!Array.isArray(list))
826
906
  continue;
827
907
  const kept = list.filter((entry) => markerCommand(entry, mark) === null);
828
- const entry = { hooks: [{ type: 'command', command }] };
908
+ const handler = { type: 'command', command };
909
+ if (timeout !== undefined)
910
+ handler.timeout = timeout;
911
+ const entry = { hooks: [handler] };
829
912
  if (matcher)
830
913
  entry.matcher = matcher;
831
914
  kept.push(entry);
@@ -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
+ }