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.
- package/README.md +36 -0
- package/dist/client/assets/{ModelPreview-Cs14IQuQ.js → ModelPreview-i9dS1A3I.js} +1 -1
- package/dist/client/assets/{abnfDiagram-VRR7QNED-DuDfdv-6.js → abnfDiagram-VRR7QNED-Cs-ty-HA.js} +1 -1
- package/dist/client/assets/{arc-CSYzGV_2.js → arc-CIX5k_JD.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-YiZ0HXNi.js → architectureDiagram-ZJ3FMSHR-DnPnQoJy.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-IaQMzmYv.js → blockDiagram-677ZJIJ3-Dhew-L5v.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-BwyuIPRL.js → c4Diagram-LMCZKHZV-BSsEMeWn.js} +1 -1
- package/dist/client/assets/channel-CASnt1pP.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-BtHQYLBT.js → chunk-2Q5K7J3B-BWm--1Mo.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-CXrK07sW.js → chunk-32BRIVSS-D_WFmuvb.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-BfBshn11.js → chunk-5VM5RSS4-CQdzqlQg.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-BJbilNaf.js → chunk-EX3LRPZG-Cm3zLQpt.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-wgcmSRlL.js → chunk-JWPE2WC7-Cmcv5sEN.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-8VZVUeDI.js → chunk-MOJQB5TN-Bam44EwJ.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-B900YBFa.js → chunk-RYQCIY6F-DIVOuE8y.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-BK4_RLz-.js → chunk-V7JOEXUC-BfOTf7yg.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-Kj5_ycjB.js → chunk-VR4S4FIN-DbiXn0dd.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-BolvPLRz.js → chunk-XXDRQBXY-C4pOe4OC.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-Da_SQWEq.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-Da_SQWEq.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-9_9_SLqv.js → cose-bilkent-JH36ORCC-CCoSPmoc.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-yEpoi3vG.js → cynefin-VYW2F7L2-B2lYz7sl.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-Dy-gvup_.js → cynefinDiagram-TSTJHNR4-Cd7crN9s.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-CNjJ5pRK.js → dagre-VKFMJZFB-q9Fj9HuQ.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-uZ73Rj3T.js → diagram-FQU43EPY-iqHy5YHW.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-Cxt7TYZT.js → diagram-G47NLZAW-BE-jlh1B.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-B4TKinuZ.js → diagram-NH7WQ7WH-DmP7_WOC.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-CGSuenHU.js → diagram-OA4YK3LP-CHx_k8Ar.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-DqqzDaUZ.js → diagram-WEI45ONY-D0wImpAF.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-B2SgXL5w.js → ebnfDiagram-CCIWWBDH-DyCB0rF8.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-D-ibkr8y.js → erDiagram-Q63AITRT-SRcd1B2Z.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-BPeyMqr-.js → flowDiagram-23GEKE2U-D0i9mFwt.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-DcEqEzzu.js → ganttDiagram-NO4QXBWP-BfwGIYDn.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-DTZ6UCQY.js → gitGraphDiagram-IHSO6WYX-BXkJQP7O.js} +1 -1
- package/dist/client/assets/index-BA-DqJM2.js +451 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-BHrzVXeX.js → infoDiagram-FWYZ7A6U-CM-Ebv9R.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-CwuoQJku.js → ishikawaDiagram-FXEZZL3T-U-qnP7xi.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-ZEcy0rDZ.js → journeyDiagram-5HDEW3XC-D4RxvsuL.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-BDBawNbr.js → kanban-definition-HUTT4EX6-Dm_asCYK.js} +1 -1
- package/dist/client/assets/{linear-smYQWzDV.js → linear-Zhe8d635.js} +1 -1
- package/dist/client/assets/{mermaid.core-eJs7Ouvw.js → mermaid.core-D_NWIuMY.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-BK1IZ9zg.js → mindmap-definition-LN4V7U3C-DXpNidd5.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-B3-mVNgX.js → pegDiagram-2B236MQR-DdOwOEgu.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-Bkeeyx5l.js → pieDiagram-ENE6RG2P-D_A9HoY_.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-DouIklEO.js → quadrantDiagram-ABIIQ3AL-D-Y3cLEF.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-BB_jXQd2.js → railroadDiagram-RFXS5EU6-DD6eFV82.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-u56IHfiR.js → requirementDiagram-TGXJPOKE-B59HJgL2.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-eDi2cs5E.js → sankeyDiagram-HTMAVEWB-C2BvHMH8.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-DnAA2xYa.js → sequenceDiagram-DBY2YBRQ-3004tCRm.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-BogPui79.js → sizeCapture-X5ZJPWSS-DCBhg8QW.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC--EeXDy2M.js → stateDiagram-2N3HPSRC-BVqiKVe0.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Z0GHWyoF.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-DZAw7-kY.js → swimlanes-5IMT3BWC-DS6-sdEW.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-el4HDX0G.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-DkcnpB-Y.js → timeline-definition-FHXFAJF6-wVOEmQwG.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-BStcDGEf.js → vennDiagram-L72KCM5P-DMZiSJDk.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-Cj4CQn1M.js → wardleyDiagram-EHGQE667-Dx5Aob4Y.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-_uBCjai8.js → xychartDiagram-FW5EYKEG-BqMhkhmW.js} +1 -1
- package/dist/client/index.html +1 -1
- package/dist/client/sw.js +1 -1
- package/dist/server/agent/autoTitle.js +16 -36
- package/dist/server/agent/installers.js +98 -15
- package/dist/server/agent/pluginPackages.js +289 -0
- package/dist/server/agent/plugins.js +164 -15
- package/dist/server/agent/registry.js +1 -1
- package/dist/server/agent/session.js +10 -1
- package/dist/server/agent/titleNamerCatalog.js +131 -2
- package/dist/server/agentHook.js +47 -0
- package/dist/server/cli.js +224 -13
- package/dist/server/entry.js +11 -2
- package/dist/server/routes/terminal.js +267 -37
- package/dist/server/utils/csrfProtection.js +4 -1
- package/dist/server/utils/settings.js +18 -5
- package/package.json +1 -1
- package/runtime-manifest.json +2 -2
- package/dist/client/assets/channel-BotxtiNE.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-CLWlbTSC.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-CLWlbTSC.js +0 -1
- package/dist/client/assets/index-ivnGnbVg.js +0 -451
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Dc-3HKTs.js +0 -1
- 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
|
|
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.
|
|
780
|
-
:
|
|
781
|
-
if (ours !==
|
|
853
|
+
? list.find((entry) => markerCommand(entry, mark) !== null)
|
|
854
|
+
: undefined;
|
|
855
|
+
if (ours !== undefined) {
|
|
782
856
|
any = true;
|
|
783
|
-
|
|
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
|
|
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
|
+
}
|